Removed all code duplication for Q-Apps API endpoints.

Requests are now internally routed to the existing API handlers. This should allow new Q-Apps API endpoints to be added much more quickly, as well as removing the need to maintain their code separately from the regular API endpoints.
This commit is contained in:
CalDescent 2023-01-19 19:56:50 +00:00
parent 2c78f4b45b
commit 3c8088e463
4 changed files with 87 additions and 343 deletions

View File

@ -15,7 +15,21 @@ public abstract class Security {
public static final String API_KEY_HEADER = "X-API-KEY";
/**
* Check API call is allowed, retrieving the API key from the request header or GET/POST parameters where required
* @param request
*/
public static void checkApiCallAllowed(HttpServletRequest request) {
checkApiCallAllowed(request, null);
}
/**
* Check API call is allowed, retrieving the API key first from the passedApiKey parameter, with a fallback
* to the request header or GET/POST parameters when null.
* @param request
* @param passedApiKey - the API key to test, or null if it should be retrieved from the request headers.
*/
public static void checkApiCallAllowed(HttpServletRequest request, String passedApiKey) {
// We may want to allow automatic authentication for local requests, if enabled in settings
boolean localAuthBypassEnabled = Settings.getInstance().isLocalAuthBypassEnabled();
if (localAuthBypassEnabled) {
@ -38,7 +52,10 @@ public abstract class Security {
}
// We require an API key to be passed
String passedApiKey = request.getHeader(API_KEY_HEADER);
if (passedApiKey == null) {
// API call not passed as a parameter, so try the header
passedApiKey = request.getHeader(API_KEY_HEADER);
}
if (passedApiKey == null) {
// Try query string - this is needed to avoid a CORS preflight. See: https://stackoverflow.com/a/43881141
passedApiKey = request.getParameter("apiKey");
@ -84,9 +101,9 @@ public abstract class Security {
}
}
public static void requirePriorAuthorizationOrApiKey(HttpServletRequest request, String resourceId, Service service, String identifier) {
public static void requirePriorAuthorizationOrApiKey(HttpServletRequest request, String resourceId, Service service, String identifier, String apiKey) {
try {
Security.checkApiCallAllowed(request);
Security.checkApiCallAllowed(request, apiKey);
} catch (ApiException e) {
// API call wasn't allowed, but maybe it was pre-authorized

View File

@ -8,9 +8,9 @@ import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.qortal.api.ApiError;
import org.qortal.api.ApiExceptionFactory;
import org.qortal.arbitrary.apps.QApp;
import org.qortal.api.*;
import org.qortal.api.model.NameSummary;
import org.qortal.api.resource.*;
import org.qortal.arbitrary.misc.Service;
import org.qortal.data.account.AccountData;
import org.qortal.data.arbitrary.ArbitraryResourceInfo;
@ -19,7 +19,7 @@ import org.qortal.data.at.ATData;
import org.qortal.data.chat.ChatMessage;
import org.qortal.data.group.GroupData;
import org.qortal.data.naming.NameData;
import org.qortal.repository.DataException;
import org.qortal.utils.Base58;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
@ -28,6 +28,8 @@ import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import java.io.IOException;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
@ -83,127 +85,128 @@ public class AppsResource {
@Path("/account")
@Hidden // For internal Q-App API use only
public AccountData getAccount(@QueryParam("address") String address) {
try {
return QApp.getAccountData(address);
} catch (DataException | IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
}
AddressesResource addressesResource = (AddressesResource) buildResource(AddressesResource.class, request, response, context);
return addressesResource.getAccountInfo(address);
}
@GET
@Path("/account/names")
@Hidden // For internal Q-App API use only
public List<NameData> getAccountNames(@QueryParam("address") String address) {
try {
return QApp.getAccountNames(address);
} catch (DataException | IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
}
public List<NameSummary> getAccountNames(@QueryParam("address") String address) {
NamesResource namesResource = (NamesResource) buildResource(NamesResource.class, request, response, context);
return namesResource.getNamesByAddress(address, 0, 0 ,false);
}
@GET
@Path("/name")
@Hidden // For internal Q-App API use only
public NameData getName(@QueryParam("name") String name) {
try {
return QApp.getNameData(name);
} catch (DataException | IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
}
NamesResource namesResource = (NamesResource) buildResource(NamesResource.class, request, response, context);
return namesResource.getName(name);
}
@GET
@Path("/chatmessages")
@Hidden // For internal Q-App API use only
public List<ChatMessage> searchChatMessages(@QueryParam("before") Long before, @QueryParam("after") Long after, @QueryParam("txGroupId") Integer txGroupId, @QueryParam("involving") List<String> involvingAddresses, @QueryParam("reference") String reference, @QueryParam("chatReference") String chatReference, @QueryParam("hasChatReference") Boolean hasChatReference, @QueryParam("limit") Integer limit, @QueryParam("offset") Integer offset, @QueryParam("reverse") Boolean reverse) {
try {
return QApp.searchChatMessages(before, after, txGroupId, involvingAddresses, reference, chatReference, hasChatReference, limit, offset, reverse);
} catch (DataException | IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
}
ChatResource chatResource = (ChatResource) buildResource(ChatResource.class, request, response, context);
return chatResource.searchChat(before, after, txGroupId, involvingAddresses, reference, chatReference, hasChatReference, limit, offset, reverse);
}
@GET
@Path("/resources")
@Hidden // For internal Q-App API use only
public List<ArbitraryResourceInfo> getResources(@QueryParam("service") Service service, @QueryParam("identifier") String identifier, @Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource, @Parameter(description = "Filter names by list") @QueryParam("nameListFilter") String nameListFilter, @Parameter(description = "Include status") @QueryParam("includeStatus") Boolean includeStatus, @Parameter(description = "Include metadata") @QueryParam("includeMetadata") Boolean includeMetadata, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
try {
return QApp.searchQdnResources(service, identifier, defaultResource, nameListFilter, includeStatus, includeMetadata, limit, offset, reverse);
} catch (DataException | IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
}
ArbitraryResource arbitraryResource = (ArbitraryResource) buildResource(ArbitraryResource.class, request, response, context);
return arbitraryResource.getResources(service, identifier, defaultResource, limit, offset, reverse, nameListFilter, includeStatus, includeMetadata);
}
@GET
@Path("/resourcestatus")
@Hidden // For internal Q-App API use only
public ArbitraryResourceStatus getResourceStatus(@QueryParam("service") Service service, @QueryParam("name") String name, @QueryParam("identifier") String identifier) {
return QApp.getQdnResourceStatus(service, name, identifier);
ArbitraryResource arbitraryResource = (ArbitraryResource) buildResource(ArbitraryResource.class, request, response, context);
ApiKey apiKey = ApiService.getInstance().getApiKey();
return arbitraryResource.getResourceStatus(apiKey.toString(), service, name, identifier, false);
}
@GET
@Path("/resource")
@Hidden // For internal Q-App API use only
public String getResource(@QueryParam("service") Service service, @QueryParam("name") String name, @QueryParam("identifier") String identifier, @QueryParam("filepath") String filepath, @QueryParam("rebuild") boolean rebuild) {
try {
return QApp.fetchQdnResource64(service, name, identifier, filepath, rebuild);
} catch (DataException | IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
}
public HttpServletResponse getResource(@QueryParam("service") Service service, @QueryParam("name") String name, @QueryParam("identifier") String identifier, @QueryParam("filepath") String filepath, @QueryParam("rebuild") boolean rebuild) {
ArbitraryResource arbitraryResource = (ArbitraryResource) buildResource(ArbitraryResource.class, request, response, context);
ApiKey apiKey = ApiService.getInstance().getApiKey();
return arbitraryResource.get(apiKey.toString(), service, name, identifier, filepath, rebuild, false, 5);
}
@GET
@Path("/groups")
@Hidden // For internal Q-App API use only
public List<GroupData> listGroups(@Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
try {
return QApp.listGroups(limit, offset, reverse);
} catch (DataException | IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
}
GroupsResource groupsResource = (GroupsResource) buildResource(GroupsResource.class, request, response, context);
return groupsResource.getAllGroups(limit, offset, reverse);
}
@GET
@Path("/balance")
@Hidden // For internal Q-App API use only
public Long getBalance(@QueryParam("assetId") long assetId, @QueryParam("address") String address) {
try {
return QApp.getBalance(assetId, address);
} catch (DataException | IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
}
public BigDecimal getBalance(@QueryParam("assetId") long assetId, @QueryParam("address") String address) {
AddressesResource addressesResource = (AddressesResource) buildResource(AddressesResource.class, request, response, context);
return addressesResource.getBalance(address, assetId);
}
@GET
@Path("/at")
@Hidden // For internal Q-App API use only
public ATData getAT(@QueryParam("atAddress") String atAddress) {
try {
return QApp.getAtInfo(atAddress);
} catch (DataException | IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
}
AtResource atResource = (AtResource) buildResource(AtResource.class, request, response, context);
return atResource.getByAddress(atAddress);
}
@GET
@Path("/atdata")
@Hidden // For internal Q-App API use only
public String getATData(@QueryParam("atAddress") String atAddress) {
try {
return QApp.getAtData58(atAddress);
} catch (DataException | IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
}
AtResource atResource = (AtResource) buildResource(AtResource.class, request, response, context);
return Base58.encode(atResource.getDataByAddress(atAddress));
}
@GET
@Path("/ats")
@Hidden // For internal Q-App API use only
public List<ATData> listATs(@QueryParam("codeHash58") String codeHash58, @QueryParam("isExecutable") Boolean isExecutable, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
AtResource atResource = (AtResource) buildResource(AtResource.class, request, response, context);
return atResource.getByFunctionality(codeHash58, isExecutable, limit, offset, reverse);
}
public static Object buildResource(Class<?> resourceClass, HttpServletRequest request, HttpServletResponse response, ServletContext context) {
try {
return QApp.listATs(codeHash58, isExecutable, limit, offset, reverse);
} catch (DataException | IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
Object resource = resourceClass.getDeclaredConstructor().newInstance();
Field requestField = resourceClass.getDeclaredField("request");
requestField.setAccessible(true);
requestField.set(resource, request);
try {
Field responseField = resourceClass.getDeclaredField("response");
responseField.setAccessible(true);
responseField.set(resource, response);
} catch (NoSuchFieldException e) {
// Ignore
}
try {
Field contextField = resourceClass.getDeclaredField("context");
contextField.setAccessible(true);
contextField.set(resource, context);
} catch (NoSuchFieldException e) {
// Ignore
}
return resource;
} catch (Exception e) {
throw new RuntimeException("Failed to build API resource " + resourceClass.getName() + ": " + e.getMessage(), e);
}
}

View File

@ -266,7 +266,7 @@ public class ArbitraryResource {
@PathParam("name") String name,
@QueryParam("build") Boolean build) {
Security.requirePriorAuthorizationOrApiKey(request, name, service, null);
Security.requirePriorAuthorizationOrApiKey(request, name, service, null, apiKey);
return ArbitraryTransactionUtils.getStatus(service, name, null, build);
}
@ -288,7 +288,7 @@ public class ArbitraryResource {
@PathParam("identifier") String identifier,
@QueryParam("build") Boolean build) {
Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier);
Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier, apiKey);
return ArbitraryTransactionUtils.getStatus(service, name, identifier, build);
}
@ -682,7 +682,7 @@ public class ArbitraryResource {
// Authentication can be bypassed in the settings, for those running public QDN nodes
if (!Settings.getInstance().isQDNAuthBypassEnabled()) {
Security.checkApiCallAllowed(request);
Security.checkApiCallAllowed(request, apiKey);
}
return this.download(service, name, identifier, filepath, rebuild, async, attempts);

View File

@ -1,276 +0,0 @@
package org.qortal.arbitrary.apps;
import org.apache.commons.lang3.ArrayUtils;
import org.bouncycastle.util.encoders.Base64;
import org.ciyam.at.MachineState;
import org.qortal.account.Account;
import org.qortal.arbitrary.ArbitraryDataFile;
import org.qortal.arbitrary.ArbitraryDataReader;
import org.qortal.arbitrary.exception.MissingDataException;
import org.qortal.arbitrary.misc.Service;
import org.qortal.asset.Asset;
import org.qortal.controller.Controller;
import org.qortal.controller.LiteNode;
import org.qortal.crypto.Crypto;
import org.qortal.data.account.AccountData;
import org.qortal.data.arbitrary.ArbitraryResourceInfo;
import org.qortal.data.arbitrary.ArbitraryResourceStatus;
import org.qortal.data.at.ATData;
import org.qortal.data.at.ATStateData;
import org.qortal.data.chat.ChatMessage;
import org.qortal.data.group.GroupData;
import org.qortal.data.naming.NameData;
import org.qortal.list.ResourceListManager;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import org.qortal.settings.Settings;
import org.qortal.utils.ArbitraryTransactionUtils;
import org.qortal.utils.Base58;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
public class QApp {
public static AccountData getAccountData(String address) throws DataException {
if (!Crypto.isValidAddress(address))
throw new IllegalArgumentException("Invalid address");
try (final Repository repository = RepositoryManager.getRepository()) {
return repository.getAccountRepository().getAccount(address);
}
}
public static List<NameData> getAccountNames(String address) throws DataException {
if (!Crypto.isValidAddress(address))
throw new IllegalArgumentException("Invalid address");
try (final Repository repository = RepositoryManager.getRepository()) {
return repository.getNameRepository().getNamesByOwner(address);
}
}
public static NameData getNameData(String name) throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
if (Settings.getInstance().isLite()) {
return LiteNode.getInstance().fetchNameData(name);
} else {
return repository.getNameRepository().fromName(name);
}
}
}
public static List<ChatMessage> searchChatMessages(Long before, Long after, Integer txGroupId, List<String> involvingAddresses,
String reference, String chatReference, Boolean hasChatReference,
Integer limit, Integer offset, Boolean reverse) throws DataException {
// Check args meet expectations
if ((txGroupId == null && involvingAddresses.size() != 2)
|| (txGroupId != null && !involvingAddresses.isEmpty()))
throw new IllegalArgumentException("Invalid txGroupId or involvingAddresses");
// Check any provided addresses are valid
if (involvingAddresses.stream().anyMatch(address -> !Crypto.isValidAddress(address)))
throw new IllegalArgumentException("Invalid address");
if (before != null && before < 1500000000000L)
throw new IllegalArgumentException("Invalid timestamp");
byte[] referenceBytes = null;
if (reference != null)
referenceBytes = Base58.decode(reference);
byte[] chatReferenceBytes = null;
if (chatReference != null)
chatReferenceBytes = Base58.decode(chatReference);
try (final Repository repository = RepositoryManager.getRepository()) {
return repository.getChatRepository().getMessagesMatchingCriteria(
before,
after,
txGroupId,
referenceBytes,
chatReferenceBytes,
hasChatReference,
involvingAddresses,
limit, offset, reverse);
}
}
public static List<ArbitraryResourceInfo> searchQdnResources(Service service, String identifier, Boolean defaultResource,
String nameListFilter, Boolean includeStatus, Boolean includeMetadata,
Integer limit, Integer offset, Boolean reverse) throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
// Treat empty identifier as null
if (identifier != null && identifier.isEmpty()) {
identifier = null;
}
// Ensure that "default" and "identifier" parameters cannot coexist
boolean defaultRes = Boolean.TRUE.equals(defaultResource);
if (defaultRes == true && identifier != null) {
throw new IllegalArgumentException("identifier cannot be specified when requesting a default resource");
}
// Load filter from list if needed
List<String> names = null;
if (nameListFilter != null) {
names = ResourceListManager.getInstance().getStringsInList(nameListFilter);
if (names.isEmpty()) {
// List doesn't exist or is empty - so there will be no matches
return new ArrayList<>();
}
}
List<ArbitraryResourceInfo> resources = repository.getArbitraryRepository()
.getArbitraryResources(service, identifier, names, defaultRes, limit, offset, reverse);
if (resources == null) {
return new ArrayList<>();
}
if (includeStatus != null && includeStatus) {
resources = ArbitraryTransactionUtils.addStatusToResources(resources);
}
if (includeMetadata != null && includeMetadata) {
resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
}
return resources;
}
}
public static ArbitraryResourceStatus getQdnResourceStatus(Service service, String name, String identifier) {
return ArbitraryTransactionUtils.getStatus(service, name, identifier, false);
}
public static String fetchQdnResource64(Service service, String name, String identifier, String filepath, boolean rebuild) throws DataException {
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
try {
int attempts = 0;
int maxAttempts = 5;
// Loop until we have data
while (!Controller.isStopping()) {
attempts++;
if (!arbitraryDataReader.isBuilding()) {
try {
arbitraryDataReader.loadSynchronously(rebuild);
break;
} catch (MissingDataException e) {
if (attempts > maxAttempts) {
// Give up after 5 attempts
throw new DataException("Data unavailable. Please try again later.");
}
}
}
Thread.sleep(3000L);
}
java.nio.file.Path outputPath = arbitraryDataReader.getFilePath();
if (outputPath == null) {
// Assume the resource doesn't exist
throw new DataException("File not found");
}
if (filepath == null || filepath.isEmpty()) {
// No file path supplied - so check if this is a single file resource
String[] files = ArrayUtils.removeElement(outputPath.toFile().list(), ".qortal");
if (files.length == 1) {
// This is a single file resource
filepath = files[0];
}
else {
throw new IllegalArgumentException("filepath is required for resources containing more than one file");
}
}
// TODO: limit file size that can be read into memory
java.nio.file.Path path = Paths.get(outputPath.toString(), filepath);
if (!Files.exists(path)) {
return null;
}
byte[] bytes = Files.readAllBytes(path);
if (bytes != null) {
return Base64.toBase64String(bytes);
}
throw new DataException("File contents could not be read");
} catch (Exception e) {
throw new DataException(String.format("Unable to fetch resource: %s", e.getMessage()));
}
}
public static List<GroupData> listGroups(Integer limit, Integer offset, Boolean reverse) throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
List<GroupData> allGroupData = repository.getGroupRepository().getAllGroups(limit, offset, reverse);
allGroupData.forEach(groupData -> {
try {
groupData.memberCount = repository.getGroupRepository().countGroupMembers(groupData.getGroupId());
} catch (DataException e) {
// Exclude memberCount for this group
}
});
return allGroupData;
}
}
public static Long getBalance(Long assetId, String address) throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
if (assetId == null)
assetId = Asset.QORT;
Account account = new Account(repository, address);
return account.getConfirmedBalance(assetId);
}
}
public static ATData getAtInfo(String atAddress) throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
ATData atData = repository.getATRepository().fromATAddress(atAddress);
if (atData == null) {
throw new IllegalArgumentException("AT not found");
}
return atData;
}
}
public static String getAtData58(String atAddress) throws DataException {
try (final Repository repository = RepositoryManager.getRepository()) {
ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress);
if (atStateData == null) {
throw new IllegalArgumentException("AT not found");
}
byte[] stateData = atStateData.getStateData();
byte[] dataBytes = MachineState.extractDataBytes(stateData);
return Base58.encode(dataBytes);
}
}
public static List<ATData> listATs(String codeHash58, Boolean isExecutable, Integer limit, Integer offset, Boolean reverse) throws DataException {
// Decode codeHash
byte[] codeHash;
try {
codeHash = Base58.decode(codeHash58);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(e);
}
// codeHash must be present and have correct length
if (codeHash == null || codeHash.length != 32)
throw new IllegalArgumentException("Invalid code hash");
// Impose a limit on 'limit'
if (limit != null && limit > 100)
throw new IllegalArgumentException("Limit is too high");
try (final Repository repository = RepositoryManager.getRepository()) {
return repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse);
}
}
}