Initial APIs and core support for Q-Apps

This commit is contained in:
CalDescent 2023-01-13 17:36:27 +00:00
parent 4232616a5f
commit 32c2f68cb1
10 changed files with 1149 additions and 52 deletions

401
Q-Apps.md Normal file
View File

@ -0,0 +1,401 @@
# Qortal Project - Q-Apps Documentation
## Introduction
Q-Apps are static web apps written in javascript, HTML, CSS, and other static assets. The key difference between a Q-App and a fully static site is its ability to interact with both the logged-in user and on-chain data. This is achieved using the API described in this document.
## Making a request
Qortal core will automatically inject a `qortalRequest()` javascript function (a Promise) to all websites/apps. This can be used to fetch or publish data to or from the Qortal blockchain. This functionality supports async/await, as well as try/catch error handling.
```
async function myfunction() {
try {
let res = await qortalRequest({
action: "GET_ACCOUNT_DATA",
address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
});
console.log(JSON.stringify(res)); // Log the response to the console
} catch(e) {
console.log("Error: " + JSON.stringify(e));
}
}
myfunction();
```
## Timeouts
By default, all requests will timeout after 10 seconds, and will throw an error - `The request timed out`. If you need a longer timeout - e.g. when fetching large QDN resources that may take a long time to be retried, you can use `qortalRequestWithTimeout(request, timeout)` as an alternative to `qortalRequest(request)`.
```
async function myfunction() {
try {
let timeout = 60000; // 60 seconds
let res = await qortalRequestWithTimeout({
action: "FETCH_QDN_RESOURCE",
name: "QortalDemo",
service: "THUMBNAIL",
identifier: "qortal_avatar"
}, timeout);
// Do something with the avatar here
} catch(e) {
console.log("Error: " + JSON.stringify(e));
}
}
myfunction();
```
## Supported methods
Here is a list of currently supported methods:
- GET_ACCOUNT_DATA
- GET_ACCOUNT_NAMES
- GET_NAME_DATA
- SEARCH_QDN_RESOURCES
- GET_QDN_RESOURCE_STATUS
- FETCH_QDN_RESOURCE
- PUBLISH_QDN_RESOURCE
- GET_WALLET_BALANCE
- GET_BALANCE
- SEND_COIN
- SEARCH_CHAT_MESSAGES
- SEND_CHAT_MESSAGE
- LIST_GROUPS
- JOIN_GROUP
- DEPLOY_AT
- GET_AT
- GET_AT_DATA
More functionality will be added in the future.
## Example Requests
Here is some example requests for each of the above:
### Get account data
```
let res = await qortalRequest({
action: "GET_ACCOUNT_DATA",
address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
});
```
### Get names owned by account
```
let res = await qortalRequest({
action: "GET_ACCOUNT_NAMES",
address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
});
```
### Get name data
```
let res = await qortalRequest({
action: "GET_NAME_DATA",
name: "QortalDemo"
});
```
### Search QDN resources
```
let res = await qortalRequest({
action: "SEARCH_QDN_RESOURCES",
service: "THUMBNAIL",
identifier: "qortal_avatar", // Optional
default: true, // Optional
nameListFilter: "FollowedNames", // Optional
includeStatus: false,
includeMetadata: false,
limit: 100,
offset: 0,
reverse: true
});
```
### Fetch QDN single file resource
Data is returned in the base64 format
```
let res = await qortalRequest({
action: "FETCH_QDN_RESOURCE",
name: "QortalDemo",
service: "THUMBNAIL",
identifier: "qortal_avatar", // Optional. If omitted, the default resource is returned, or you can alternatively use the keyword "default"
rebuild: false
});
```
### Fetch file from multi file QDN resource
Data is returned in the base64 format
```
let res = await qortalRequest({
action: "FETCH_QDN_RESOURCE",
name: "QortalDemo",
service: "WEBSITE",
identifier: "default", // Optional. If omitted, the default resource is returned, or you can alternatively request that using the keyword "default", as shown here
filepath: "index.html", // Required only for resources containing more than one file
rebuild: false
});
```
### Get QDN resource status
```
let res = await qortalRequest({
action: "GET_QDN_RESOURCE_STATUS",
name: "QortalDemo",
service: "THUMBNAIL",
identifier: "qortal_avatar" // Optional
});
```
### Publish QDN resource
_Requires user approval_
```
await qortalRequest({
action: "PUBLISH_QDN_RESOURCE",
name: "Demo", // Publisher must own the registered name - use GET_ACCOUNT_NAMES for a list
service: "WEBSITE",
data64: "base64_encoded_data",
title: "Title",
description: "Description",
category: "TECHNOLOGY",
tags: ["tag1", "tag2", "tag3", "tag4", "tag5"]
});
```
### Get wallet balance (QORT)
_Requires user approval_
```
await qortalRequest({
action: "GET_WALLET_BALANCE",
coin: "QORT"
});
```
### Get wallet balance (foreign coin)
_Requires user approval_
```
await qortalRequest({
action: "GET_WALLET_BALANCE",
coin: "LTC"
});
```
### Get address or asset balance
```
let res = await qortalRequest({
action: "GET_BALANCE",
address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
});
```
```
let res = await qortalRequest({
action: "GET_BALANCE",
assetId: 1,
address: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2"
});
```
### Send coin to address
_Requires user approval_
```
await qortalRequest({
action: "SEND_COIN",
coin: "QORT",
destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2",
amount: 100000000, // 1 QORT
fee: 10000 // 0.0001 QORT
});
```
### Send coin to address
_Requires user approval_
```
await qortalRequest({
action: "SEND_COIN",
coin: "LTC",
destinationAddress: "LSdTvMHRm8sScqwCi6x9wzYQae8JeZhx6y",
amount: 100000000, // 1 LTC
fee: 20 // 0.00000020 LTC per byte
});
```
### Search or list chat messages
```
let res = await qortalRequest({
action: "SEARCH_CHAT_MESSAGES",
before: 999999999999999,
after: 0,
txGroupId: 0, // Optional (must specify either txGroupId or two involving addresses)
// involving: ["QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2", "QSefrppsDCsZebcwrqiM1gNbWq7YMDXtG2"], // Optional (must specify either txGroupId or two involving addresses)
// reference: "reference", // Optional
// chatReference: "chatreference", // Optional
// hasChatReference: true, // Optional
limit: 100,
offset: 0,
reverse: true
});
```
### Send a group chat message
_Requires user approval_
```
await qortalRequest({
action: "SEND_CHAT_MESSAGE",
groupId: 0,
message: "Test"
});
```
### Send a private chat message
_Requires user approval_
```
await qortalRequest({
action: "SEND_CHAT_MESSAGE",
destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2",
message: "Test"
});
```
### List groups
```
let res = await qortalRequest({
action: "LIST_GROUPS",
limit: 100,
offset: 0,
reverse: true
});
```
### Join a group
_Requires user approval_
```
await qortalRequest({
action: "JOIN_GROUP",
groupId: 100
});
```
### Deploy an AT
_Requires user approval_
```
let res = await qortalRequest({
action: "DEPLOY_AT",
creationBytes: "12345",
name: "test name",
description: "test description",
type: "test type",
tags: "test tags",
amount: 100000000, // 1 QORT
assetId: 0,
fee: 20000 // 0.0002 QORT
});
```
### Get AT info
```
let res = await qortalRequest({
action: "GET_AT",
atAddress: "ASRUsCjk6fa5bujv3oWYmWaVqNtvxydpPH"
});
```
### Get AT data bytes (base58 encoded)
```
let res = await qortalRequest({
action: "GET_AT_DATA",
atAddress: "ASRUsCjk6fa5bujv3oWYmWaVqNtvxydpPH"
});
```
### List ATs by functionality
```
let res = await qortalRequest({
action: "LIST_ATS",
codeHash58: "4KdJETRAdymE7dodDmJbf5d9L1bp4g5Nxky8m47TBkvA",
isExecutable: true,
limit: 100,
offset: 0,
reverse: true
});
```
## Sample App
Here is a sample application to display the logged-in user's avatar:
```
<html>
<head>
<script>
try {
// Get QORT address of logged in account
let address = await qortalRequest({
action: "GET_ACCOUNT_ADDRESS"
});
console.log("address: " + address);
// Get names owned by this account
let names = await qortalRequest({
action: "GET_ACCOUNT_NAMES",
address: address
});
console.log("names: " + JSON.stringify(names));
if (names.size == 0) {
console.log("User has no registered names");
return;
}
// Download the avatar of the first registered name
let avatar = await qortalRequest({
action: "FETCH_QDN_RESOURCE",
name: names[0].name,
service: "THUMBNAIL",
identifier: "qortal_avatar"
});
console.log("avatar: " + JSON.stringify(avatar));
// Display the avatar image on the screen
document.getElementsById("avatar").src = "data:image/png;base64," + avatar;
} catch(e) {
console.log("Error: " + JSON.stringify(e));
}
</script>
</head>
<body>
<img width="500" id="avatar" />
</body>
</html>
```
## Testing and Development
Publishing an in-development app to mainnet isn't recommended. There are several options for developing and testing a Q-app before publishing to mainnet:
### Preview mode
All read-only operations can be tested using preview mode. It can be used as follows:
1. Ensure Qortal core is running locally on the machine you are developing on. Previewing via a remote node is not currently possible.
2. Make a local API call to `POST /render/preview`, passing in the API key (found in apikey.txt), and the path to the root of your Q-App, for example:
```
curl -X POST "http://localhost:12391/render/preview" -H "X-API-KEY: apiKeyGoesHere" -d "/home/username/Websites/MyApp"
```
3. This returns a URL, which can be copied and pasted into a browser to view the preview
4. Modify the Q-App as required, then repeat from step 2 to generate a new preview URL
This is a short term method until preview functionality has been implemented within the UI.
### Single node testnet
For full read/write testing of a Q-App, you can set up a single node testnet (often referred to as devnet) on your local machine. See [Single Node Testnet Quick Start Guide](TestNets.md#quick-start).

View File

@ -110,6 +110,7 @@ Your options are:
}
```
<a name="quick-start"></a>
## Quick start
Here are some steps to quickly get a single node testnet up and running with a generic minting account:
1. Start with template `settings-test.json`, and create a `testchain.json` based on mainnet's blockchain.json (or obtain one from Qortal developers). These should be in the same directory as the jar.

View File

@ -53,7 +53,7 @@ public class ApiService {
private ApiService() {
this.config = new ResourceConfig();
this.config.packages("org.qortal.api.resource");
this.config.packages("org.qortal.api.resource", "org.qortal.api.apps.resource");
this.config.register(OpenApiResource.class);
this.config.register(ApiDefinition.class);
this.config.register(AnnotationPostProcessor.class);

View File

@ -37,7 +37,7 @@ public class GatewayService {
private GatewayService() {
this.config = new ResourceConfig();
this.config.packages("org.qortal.api.gateway.resource");
this.config.packages("org.qortal.api.gateway.resource", "org.qortal.api.apps.resource");
this.config.register(OpenApiResource.class);
this.config.register(ApiDefinition.class);
this.config.register(AnnotationPostProcessor.class);

View File

@ -25,6 +25,10 @@ public class HTMLParser {
String baseUrl = this.linkPrefix + "/";
Elements head = document.getElementsByTag("head");
if (!head.isEmpty()) {
// Add q-apps script tag
String qAppsScriptElement = String.format("<script src=\"/apps/q-apps.js?time=%d\">", System.currentTimeMillis());
head.get(0).prepend(qAppsScriptElement);
// Add base href tag
String baseElement = String.format("<base href=\"%s\">", baseUrl);
head.get(0).prepend(baseElement);

View File

@ -0,0 +1,210 @@
package org.qortal.api.apps.resource;
import com.google.common.io.Resources;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
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.arbitrary.misc.Service;
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.chat.ChatMessage;
import org.qortal.data.group.GroupData;
import org.qortal.data.naming.NameData;
import org.qortal.repository.DataException;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import java.io.IOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
@Path("/apps")
@Tag(name = "Apps")
public class AppsResource {
@Context HttpServletRequest request;
@Context HttpServletResponse response;
@Context ServletContext context;
@GET
@Path("/q-apps.js")
@Hidden // For internal Q-App API use only
@Operation(
summary = "Javascript interface for Q-Apps",
responses = {
@ApiResponse(
description = "javascript",
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "string"
)
)
)
}
)
public String getQAppsJs() {
URL url = Resources.getResource("q-apps/q-apps.js");
try {
return Resources.toString(url, StandardCharsets.UTF_8);
} catch (IOException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FILE_NOT_FOUND);
}
}
@GET
@Path("/q-apps-helper.js")
@Hidden // For testing only
public String getQAppsHelperJs() {
URL url = Resources.getResource("q-apps/q-apps-helper.js");
try {
return Resources.toString(url, StandardCharsets.UTF_8);
} catch (IOException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FILE_NOT_FOUND);
}
}
@GET
@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());
}
}
@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());
}
}
@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());
}
}
@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());
}
}
@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());
}
}
@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);
}
@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());
}
}
@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());
}
}
@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());
}
}
@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());
}
}
@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());
}
}
@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) {
try {
return QApp.listATs(codeHash58, isExecutable, limit, offset, reverse);
} catch (DataException | IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
}
}
}

View File

@ -128,10 +128,10 @@ public class ArbitraryResource {
}
if (includeStatus != null && includeStatus) {
resources = this.addStatusToResources(resources);
resources = ArbitraryTransactionUtils.addStatusToResources(resources);
}
if (includeMetadata != null && includeMetadata) {
resources = this.addMetadataToResources(resources);
resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
}
return resources;
@ -175,10 +175,10 @@ public class ArbitraryResource {
}
if (includeStatus != null && includeStatus) {
resources = this.addStatusToResources(resources);
resources = ArbitraryTransactionUtils.addStatusToResources(resources);
}
if (includeMetadata != null && includeMetadata) {
resources = this.addMetadataToResources(resources);
resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
}
return resources;
@ -232,10 +232,10 @@ public class ArbitraryResource {
.getArbitraryResources(service, identifier, Arrays.asList(name), defaultRes, null, null, reverse);
if (includeStatus != null && includeStatus) {
resources = this.addStatusToResources(resources);
resources = ArbitraryTransactionUtils.addStatusToResources(resources);
}
if (includeMetadata != null && includeMetadata) {
resources = this.addMetadataToResources(resources);
resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
}
creatorName.resources = resources;
@ -511,10 +511,10 @@ public class ArbitraryResource {
}
if (includeStatus != null && includeStatus) {
resources = this.addStatusToResources(resources);
resources = ArbitraryTransactionUtils.addStatusToResources(resources);
}
if (includeMetadata != null && includeMetadata) {
resources = this.addMetadataToResources(resources);
resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
}
return resources;
@ -1258,42 +1258,4 @@ public class ArbitraryResource {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, e.getMessage());
}
}
private List<ArbitraryResourceInfo> addStatusToResources(List<ArbitraryResourceInfo> resources) {
// Determine and add the status of each resource
List<ArbitraryResourceInfo> updatedResources = new ArrayList<>();
for (ArbitraryResourceInfo resourceInfo : resources) {
try {
ArbitraryDataResource resource = new ArbitraryDataResource(resourceInfo.name, ResourceIdType.NAME,
resourceInfo.service, resourceInfo.identifier);
ArbitraryResourceStatus status = resource.getStatus(true);
if (status != null) {
resourceInfo.status = status;
}
updatedResources.add(resourceInfo);
} catch (Exception e) {
// Catch and log all exceptions, since some systems are experiencing 500 errors when including statuses
LOGGER.info("Caught exception when adding status to resource %s: %s", resourceInfo, e.toString());
}
}
return updatedResources;
}
private List<ArbitraryResourceInfo> addMetadataToResources(List<ArbitraryResourceInfo> resources) {
// Add metadata fields to each resource if they exist
List<ArbitraryResourceInfo> updatedResources = new ArrayList<>();
for (ArbitraryResourceInfo resourceInfo : resources) {
ArbitraryDataResource resource = new ArbitraryDataResource(resourceInfo.name, ResourceIdType.NAME,
resourceInfo.service, resourceInfo.identifier);
ArbitraryDataTransactionMetadata transactionMetadata = resource.getLatestTransactionMetadata();
ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata);
if (resourceMetadata != null) {
resourceInfo.metadata = resourceMetadata;
}
updatedResources.add(resourceInfo);
}
return updatedResources;
}
}

View File

@ -0,0 +1,276 @@
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);
}
}
}

View File

@ -3,11 +3,11 @@ package org.qortal.utils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qortal.arbitrary.ArbitraryDataFile;
import org.qortal.arbitrary.ArbitraryDataFileChunk;
import org.qortal.arbitrary.ArbitraryDataReader;
import org.qortal.arbitrary.ArbitraryDataResource;
import org.qortal.arbitrary.*;
import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata;
import org.qortal.arbitrary.misc.Service;
import org.qortal.data.arbitrary.ArbitraryResourceInfo;
import org.qortal.data.arbitrary.ArbitraryResourceMetadata;
import org.qortal.data.arbitrary.ArbitraryResourceStatus;
import org.qortal.data.transaction.ArbitraryTransactionData;
import org.qortal.data.transaction.TransactionData;
@ -440,4 +440,41 @@ public class ArbitraryTransactionUtils {
return resource.getStatus(false);
}
public static List<ArbitraryResourceInfo> addStatusToResources(List<ArbitraryResourceInfo> resources) {
// Determine and add the status of each resource
List<ArbitraryResourceInfo> updatedResources = new ArrayList<>();
for (ArbitraryResourceInfo resourceInfo : resources) {
try {
ArbitraryDataResource resource = new ArbitraryDataResource(resourceInfo.name, ArbitraryDataFile.ResourceIdType.NAME,
resourceInfo.service, resourceInfo.identifier);
ArbitraryResourceStatus status = resource.getStatus(true);
if (status != null) {
resourceInfo.status = status;
}
updatedResources.add(resourceInfo);
} catch (Exception e) {
// Catch and log all exceptions, since some systems are experiencing 500 errors when including statuses
LOGGER.info("Caught exception when adding status to resource %s: %s", resourceInfo, e.toString());
}
}
return updatedResources;
}
public static List<ArbitraryResourceInfo> addMetadataToResources(List<ArbitraryResourceInfo> resources) {
// Add metadata fields to each resource if they exist
List<ArbitraryResourceInfo> updatedResources = new ArrayList<>();
for (ArbitraryResourceInfo resourceInfo : resources) {
ArbitraryDataResource resource = new ArbitraryDataResource(resourceInfo.name, ArbitraryDataFile.ResourceIdType.NAME,
resourceInfo.service, resourceInfo.identifier);
ArbitraryDataTransactionMetadata transactionMetadata = resource.getLatestTransactionMetadata();
ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata);
if (resourceMetadata != null) {
resourceInfo.metadata = resourceMetadata;
}
updatedResources.add(resourceInfo);
}
return updatedResources;
}
}

View File

@ -0,0 +1,206 @@
function httpGet(url) {
var request = new XMLHttpRequest();
request.open("GET", url, false);
request.send(null);
return request.responseText;
}
function handleResponse(event, response) {
if (event == null) {
return;
}
// Handle emmpty or missing responses
if (response == null || response.length == 0) {
response = "{\"error\": \"Empty response\"}"
}
// Parse response
let responseObj;
try {
responseObj = JSON.parse(response);
} catch (e) {
// Not all responses will be JSON
responseObj = response;
}
// Respond to app
if (responseObj.error != null) {
event.ports[0].postMessage({
result: null,
error: responseObj
});
}
else {
event.ports[0].postMessage({
result: responseObj,
error: null
});
}
}
window.addEventListener("message", (event) => {
if (event == null || event.data == null || event.data.length == 0) {
return;
}
if (event.data.action == null) {
// This could be a response from the UI
handleResponse(event, event.data);
}
if (event.data.requestedHandler != null && event.data.requestedHandler === "UI") {
// This request was destined for the UI, so ignore it
return;
}
console.log("Core received event: " + JSON.stringify(event.data));
let url;
let response;
let data = event.data;
switch (data.action) {
case "GET_ACCOUNT_DATA":
response = httpGet("/apps/account?address=" + data.address);
break;
case "GET_ACCOUNT_NAMES":
response = httpGet("/apps/account/names?address=" + data.address);
break;
case "GET_NAME_DATA":
response = httpGet("/apps/name?name=" + data.name);
break;
case "SEARCH_QDN_RESOURCES":
url = "/apps/resources?";
if (data.service != null) url = url.concat("&service=" + data.service);
if (data.identifier != null) url = url.concat("&identifier=" + data.identifier);
if (data.default != null) url = url.concat("&default=" + data.default);
if (data.nameListFilter != null) url = url.concat("&nameListFilter=" + data.nameListFilter);
if (data.includeStatus != null) url = url.concat("&includeStatus=" + new Boolean(data.includeStatus).toString());
if (data.includeMetadata != null) url = url.concat("&includeMetadata=" + new Boolean(data.includeMetadata).toString());
if (data.limit != null) url = url.concat("&limit=" + data.limit);
if (data.offset != null) url = url.concat("&offset=" + data.offset);
if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
response = httpGet(url);
break;
case "FETCH_QDN_RESOURCE":
url = "/apps/resource?";
if (data.service != null) url = url.concat("&service=" + data.service);
if (data.name != null) url = url.concat("&name=" + data.name);
if (data.identifier != null) url = url.concat("&identifier=" + data.identifier);
if (data.filepath != null) url = url.concat("&filepath=" + data.filepath);
if (data.rebuild != null) url = url.concat("&rebuild=" + new Boolean(data.rebuild).toString())
response = httpGet(url);
break;
case "GET_QDN_RESOURCE_STATUS":
url = "/apps/resourcestatus?";
if (data.service != null) url = url.concat("&service=" + data.service);
if (data.name != null) url = url.concat("&name=" + data.name);
if (data.identifier != null) url = url.concat("&identifier=" + data.identifier);
response = httpGet(url);
break;
case "SEARCH_CHAT_MESSAGES":
url = "/apps/chatmessages?";
if (data.before != null) url = url.concat("&before=" + data.before);
if (data.after != null) url = url.concat("&after=" + data.after);
if (data.txGroupId != null) url = url.concat("&txGroupId=" + data.txGroupId);
if (data.involving != null) data.involving.forEach((x, i) => url = url.concat("&involving=" + x));
if (data.reference != null) url = url.concat("&reference=" + data.reference);
if (data.chatReference != null) url = url.concat("&chatReference=" + data.chatReference);
if (data.hasChatReference != null) url = url.concat("&hasChatReference=" + new Boolean(data.hasChatReference).toString());
if (data.limit != null) url = url.concat("&limit=" + data.limit);
if (data.offset != null) url = url.concat("&offset=" + data.offset);
if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
response = httpGet(url);
break;
case "LIST_GROUPS":
url = "/apps/groups?";
if (data.limit != null) url = url.concat("&limit=" + data.limit);
if (data.offset != null) url = url.concat("&offset=" + data.offset);
if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
response = httpGet(url);
break;
case "GET_BALANCE":
url = "/apps/balance?";
if (data.assetId != null) url = url.concat("&assetId=" + data.assetId);
if (data.address != null) url = url.concat("&address=" + data.address);
response = httpGet(url);
break;
case "GET_AT":
url = "/apps/at?";
if (data.atAddress != null) url = url.concat("&atAddress=" + data.atAddress);
response = httpGet(url);
break;
case "GET_AT_DATA":
url = "/apps/atdata?";
if (data.atAddress != null) url = url.concat("&atAddress=" + data.atAddress);
response = httpGet(url);
break;
case "LIST_ATS":
url = "/apps/ats?";
if (data.codeHash58 != null) url = url.concat("&codeHash58=" + data.codeHash58);
if (data.isExecutable != null) url = url.concat("&isExecutable=" + data.isExecutable);
if (data.limit != null) url = url.concat("&limit=" + data.limit);
if (data.offset != null) url = url.concat("&offset=" + data.offset);
if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString());
response = httpGet(url);
break;
default:
// Pass to parent (UI), in case they can fulfil this request
event.data.requestedHandler = "UI";
parent.postMessage(event.data, '*', [event.ports[0]]);
return;
}
handleResponse(event, response);
}, false);
const awaitTimeout = (timeout, reason) =>
new Promise((resolve, reject) =>
setTimeout(
() => (reason === undefined ? resolve() : reject(reason)),
timeout
)
);
/**
* Make a Qortal (Q-Apps) request with no timeout
*/
const qortalRequestWithNoTimeout = (request) => new Promise((res, rej) => {
const channel = new MessageChannel();
channel.port1.onmessage = ({data}) => {
channel.port1.close();
if (data.error) {
rej(data.error);
} else {
res(data.result);
}
};
window.postMessage(request, '*', [channel.port2]);
});
/**
* Make a Qortal (Q-Apps) request with the default timeout (10 seconds)
*/
const qortalRequest = (request) =>
Promise.race([qortalRequestWithNoTimeout(request), awaitTimeout(10000, "The request timed out")]);
/**
* Make a Qortal (Q-Apps) request with a custom timeout, specified in milliseconds
*/
const qortalRequestWithTimeout = (request, timeout) =>
Promise.race([qortalRequestWithNoTimeout(request), awaitTimeout(timeout, "The request timed out")]);