forked from Qortal/qortal
Compare commits
344 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
49063e54ec | ||
|
df3c68679f | ||
|
81788610c4 | ||
|
fc10b61193 | ||
|
05b4ecd4ed | ||
|
aba589c0e0 | ||
|
c682fa89fd | ||
|
21d1750779 | ||
|
923e90ebed | ||
|
9490c62242 | ||
|
c941bc6024 | ||
|
0acf0729e9 | ||
|
1f77ee535f | ||
|
b693a514fd | ||
|
b571931127 | ||
|
92b983a16e | ||
|
3f71a63512 | ||
|
86b5bae320 | ||
|
3775135e0c | ||
|
c172a5764b | ||
|
1a5e3b4fb1 | ||
|
f39b6a15da | ||
|
2dfee13d86 | ||
|
b9d81645f8 | ||
|
9547a087b2 | ||
|
e014a207ef | ||
|
611240650e | ||
|
c71dce92b5 | ||
|
34c3adf280 | ||
|
95a1c6bf8b | ||
|
36e944d7e2 | ||
|
f044166b81 | ||
|
aed1823afb | ||
|
6dfaaf0054 | ||
|
45bc2e46d6 | ||
|
46e2e1043d | ||
|
a3518d1f05 | ||
|
0a1ab3d685 | ||
|
5dbacc4db3 | ||
|
1ce2dcfb2b | ||
|
ed6333f82e | ||
|
f27c9193c7 | ||
|
e48529704c | ||
|
53508f9298 | ||
|
33aeec7e87 | ||
|
16dc23ddc7 | ||
|
e80494b784 | ||
|
111ec3b483 | ||
|
db4a9ee880 | ||
|
b1ebe1864b | ||
|
3c251c35ea | ||
|
4954a1744b | ||
|
a7bbad17d7 | ||
|
8ca9423c52 | ||
|
32b9b7e578 | ||
|
f045e10ada | ||
|
560282dc1d | ||
|
9cd6372161 | ||
|
2370a67b8a | ||
|
0993903aa0 | ||
|
f5e9b91d6b | ||
|
7fe507a497 | ||
|
10f12221c9 | ||
|
85980e4cfc | ||
|
7bb6b84e86 | ||
|
dc25d33739 | ||
|
358e67b050 | ||
|
8331241d75 | ||
|
e041748b48 | ||
|
06691af729 | ||
|
cfe6dfcd1c | ||
|
3f00cda847 | ||
|
a286db2dfd | ||
|
28bd4adcd2 | ||
|
61b7cdd025 | ||
|
250245d5e1 | ||
|
0258d2bcb6 | ||
|
735de93848 | ||
|
57485bfe36 | ||
|
ed05560413 | ||
|
892b667f86 | ||
|
ea7a2224d3 | ||
|
20893879ca | ||
|
b08e845dbb | ||
|
e60cd96514 | ||
|
e2a2a1f956 | ||
|
7f53983d77 | ||
|
ce52b39495 | ||
|
3296779125 | ||
|
3dcd9d237c | ||
|
23ec71d7be | ||
|
5bbde4dcdb | ||
|
dc2da8b283 | ||
|
f3772d19f5 | ||
|
35def54ecc | ||
|
2086a2c476 | ||
|
4835e5732d | ||
|
d831972005 | ||
|
f6914821d3 | ||
|
073d124aef | ||
|
a83e332c11 | ||
|
7deb9328fa | ||
|
e598d7476b | ||
|
85735fabb2 | ||
|
7392082875 | ||
|
88f8041b05 | ||
|
3109c3bb16 | ||
|
8d462dedfa | ||
|
fdd9741936 | ||
|
929d0ac897 | ||
|
952d18390b | ||
|
bc026d9d1c | ||
|
ea2577d1c3 | ||
|
c78593cf15 | ||
|
de4523c34e | ||
|
b08329dcf1 | ||
|
668be633c4 | ||
|
ea6225ab9a | ||
|
055b66e835 | ||
|
2a7a2d3220 | ||
|
73a7c1fe7e | ||
|
2848ae695c | ||
|
713fd4f0c6 | ||
|
519bb10c60 | ||
|
3a64336d9f | ||
|
5ecc633fd7 | ||
|
1b9afce21f | ||
|
f9f34a61ac | ||
|
46b225cdfb | ||
|
4ce3b2a786 | ||
|
87ed49a2ee | ||
|
a555f503eb | ||
|
50780aba53 | ||
|
2bee3cbb5c | ||
|
534a44d0ce | ||
|
469c1af0ef | ||
|
5656100197 | ||
|
d9cac6db39 | ||
|
98b0b1932d | ||
|
9968865d0e | ||
|
05eb337367 | ||
|
5386db8a3f | ||
|
edae7fd844 | ||
|
4840804d32 | ||
|
b5cb5f1da3 | ||
|
101023ba1d | ||
|
ed73162881 | ||
|
0388626e42 | ||
|
c5c0dcf0f2 | ||
|
384f592f59 | ||
|
1528e05e0b | ||
|
82c66c0555 | ||
|
b5ce8d5fb3 | ||
|
b4a736c5d2 | ||
|
4afbca7ed2 | ||
|
44aa0a6026 | ||
|
b1452bddf3 | ||
|
96ac883515 | ||
|
b6803490b9 | ||
|
3739920ad3 | ||
|
7f21ea7e00 | ||
|
83b0ce53e6 | ||
|
d6ab9eb066 | ||
|
ac60ef30a3 | ||
|
94f14a39e3 | ||
|
4b7844dc06 | ||
|
c40d0cc67b | ||
|
3318093a4f | ||
|
cf0681d7df | ||
|
7d7cea3278 | ||
|
7d38fa909d | ||
|
0b05de22a0 | ||
|
308196250e | ||
|
b254ca7706 | ||
|
9ea2d7ab09 | ||
|
d166f625d0 | ||
|
8e2dd60ea0 | ||
|
d51f9368ef | ||
|
b17035c864 | ||
|
fa14568cb9 | ||
|
64cd21b0dd | ||
|
abdc265fc6 | ||
|
1153519d78 | ||
|
0af6fbe1eb | ||
|
d54006caf7 | ||
|
e1771dbaea | ||
|
cc98abeffb | ||
|
a3702ac6b0 | ||
|
c1ffe557e1 | ||
|
c310a7c5e8 | ||
|
c5a0b00cde | ||
|
69902f7f5b | ||
|
999e8b8aca | ||
|
466c727dee | ||
|
ba9f3b335c | ||
|
148ca0af05 | ||
|
c39b9c764b | ||
|
d30eb6141a | ||
|
52c806f9e6 | ||
|
b2d31a7e02 | ||
|
cfa0b1d8ea | ||
|
edacce1bac | ||
|
074cba2266 | ||
|
7f23ef64a2 | ||
|
5b7e9666dc | ||
|
f4a32d19dd | ||
|
eb6d84c04d | ||
|
26587067d8 | ||
|
227d93a31e | ||
|
76f17dda53 | ||
|
830bae3dc1 | ||
|
ec09312cc5 | ||
|
11654ba9c6 | ||
|
ea356d1026 | ||
|
e7a3e511bd | ||
|
6fca30ce75 | ||
|
e903e59f7f | ||
|
bef170df7e | ||
|
386bfa4e20 | ||
|
6f867031e2 | ||
|
8f589391a6 | ||
|
30c9f63cb1 | ||
|
952b21d9bd | ||
|
1f410a503e | ||
|
ae5b713e58 | ||
|
257ca2da05 | ||
|
d27316eb64 | ||
|
64d8353629 | ||
|
f5e30eeaf5 | ||
|
21f5d9a3d0 | ||
|
3077810ea8 | ||
|
4ba2f7ad6a | ||
|
8eba0f89fe | ||
|
600f98ddab | ||
|
eb07e6613f | ||
|
6c445ff646 | ||
|
4d9cece9fa | ||
|
8beffd4dae | ||
|
566c6a3f4b | ||
|
1be3ae267e | ||
|
7af551fbc5 | ||
|
6ba6c58843 | ||
|
ab34fae810 | ||
|
42f2d015b7 | ||
|
2181ece28d | ||
|
03a5d0e5f9 | ||
|
352f094272 | ||
|
ca09dd264f | ||
|
eea98d0bc7 | ||
|
9c58faa7c2 | ||
|
3cdfa4e276 | ||
|
380ba5b8c2 | ||
|
04f248bcdd | ||
|
37b20aac66 | ||
|
e1e52b3165 | ||
|
c5c826453b | ||
|
e86b9b1caf | ||
|
46e8baac98 | ||
|
3b6e1ea27f | ||
|
5a1cc7a0de | ||
|
0ec5e39517 | ||
|
bede5a71f8 | ||
|
5e750b4283 | ||
|
4a42dc2d00 | ||
|
7fc170575c | ||
|
876658256f | ||
|
a24ba40d5c | ||
|
06d8a21714 | ||
|
ae44065d7e | ||
|
6ad0989ea2 | ||
|
5962ebd08a | ||
|
bf06d47842 | ||
|
d7b1615d4f | ||
|
8c708558cb | ||
|
6b36d94c6f | ||
|
8c41a4a6b3 | ||
|
8dffe1e3ac | ||
|
932a553b91 | ||
|
1d568fa462 | ||
|
328ba48224 | ||
|
6196841609 | ||
|
9f30571b12 | ||
|
1f7fec6251 | ||
|
c3f19ea0c1 | ||
|
e31515b4a2 | ||
|
8ad46b6344 | ||
|
57eacbdd59 | ||
|
86d6037af3 | ||
|
ca80fd5f9c | ||
|
03a54691a1 | ||
|
3c8088e463 | ||
|
2f7912abce | ||
|
64529e8abf | ||
|
9d81ea7744 | ||
|
688acd466c | ||
|
81cf46f5dd | ||
|
4c52d6f0fc | ||
|
de47a94677 | ||
|
bd4c47dba6 | ||
|
c03f271825 | ||
|
dfe3754afc | ||
|
30105199a2 | ||
|
e91e612b55 | ||
|
2a55eba1f7 | ||
|
39e59cbcf8 | ||
|
016191bdb0 | ||
|
0596a07c7d | ||
|
c62c59b445 | ||
|
f78101e9cc | ||
|
476fdcb31d | ||
|
02d5043ef7 | ||
|
0ad9e2f65b | ||
|
4dc0033a5a | ||
|
745cfe8ea1 | ||
|
6284a4691c | ||
|
41f88be55e | ||
|
ba95f8376f | ||
|
8e97c05b56 | ||
|
2c78f4b45b | ||
|
613ce84df8 | ||
|
2822d860d8 | ||
|
5a052a4f67 | ||
|
32c2f68cb1 | ||
|
4232616a5f | ||
|
8ddcae249c | ||
|
eb569304ba | ||
|
b0486f44bb | ||
|
cecf28ab7b | ||
|
98b92a5bf1 | ||
|
6b45901c47 | ||
|
2b6ae57a27 | ||
|
055775b13d | ||
|
9d74f0eec0 | ||
|
09014d07e0 | ||
|
1dd039fb2d | ||
|
23a5c5f9b4 | ||
|
a4759a0ef4 | ||
|
910191b074 | ||
|
8ddf4c9f9f | ||
|
1abceada20 | ||
|
4c463f65b7 | ||
|
ff40b8f8ab | ||
|
c03344caae | ||
|
237b39a524 |
6
.github/workflows/pr-testing.yml
vendored
6
.github/workflows/pr-testing.yml
vendored
@@ -8,16 +8,16 @@ jobs:
|
||||
mavenTesting:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Cache local Maven repository
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
- name: Set up the Java JDK
|
||||
uses: actions/setup-java@v2
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '11'
|
||||
distribution: 'adopt'
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,7 +14,6 @@
|
||||
/.mvn.classpath
|
||||
/notes*
|
||||
/settings.json
|
||||
/testnet*
|
||||
/settings*.json
|
||||
/testchain*.json
|
||||
/run-testnet*.sh
|
||||
|
897
Q-Apps.md
Normal file
897
Q-Apps.md
Normal file
@@ -0,0 +1,897 @@
|
||||
# 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.
|
||||
|
||||
|
||||
# Section 0: Basic QDN concepts
|
||||
|
||||
## Introduction to QDN resources
|
||||
Each published item on QDN (Qortal Data Network) is referred to as a "resource". A resource could contain anything from a few characters of text, to a multi-layered directory structure containing thousands of files.
|
||||
|
||||
Resources are stored on-chain, however the data payload is generally stored off-chain, and verified using an on-chain SHA-256 hash.
|
||||
|
||||
To publish a resource, a user must first register a name, and then include that name when publishing the data. Accounts without a registered name are unable to publish to QDN from a Q-App at this time.
|
||||
|
||||
Owning the name grants update privileges to the data. If that name is later sold or transferred, the permission to update that resource is moved to the new owner.
|
||||
|
||||
|
||||
## Name, service & identifier
|
||||
|
||||
Each QDN resource has 3 important fields:
|
||||
- `name` - the registered name of the account that is publishing the data (which will hold update/edit privileges going forwards).<br /><br />
|
||||
- `service` - the type of content (e.g. IMAGE or JSON). Different services have different validation rules. See [list of available services](#services).<br /><br />
|
||||
- `identifier` - an optional string to allow more than one resource to exist for a given name/service combination. For example, the name `QortalDemo` may wish to publish multiple images. This can be achieved by using a different identifier string for each. The identifier is only unique to the name in question, and so it doesn't matter if another name is using the same service and identifier string.
|
||||
|
||||
|
||||
## Shared identifiers
|
||||
|
||||
Since an identifier can be used by multiple names, this can be used to the advantage of Q-App developers as it allows for data to be stored in a deterministic location.
|
||||
|
||||
An example of this is the user's avatar. This will always be published with service `THUMBNAIL` and identifier `qortal_avatar`, along with the user's name. So, an app can display the avatar of a user just by specifying their name when requesting the data. The same applies when publishing data.
|
||||
|
||||
|
||||
## "Default" resources
|
||||
|
||||
A "default" resource refers to one without an identifier. For example, when a website is published via the UI, it will use the user's name and the service `WEBSITE`. These do not have an identifier, and are therefore the "default" website for this name. When requesting or publishing data without an identifier, apps can either omit the `identifier` key entirely, or include `"identifier": "default"` to indicate that the resource(s) being queried or published do not have an identifier.
|
||||
|
||||
|
||||
<a name="services"></a>
|
||||
## Available service types
|
||||
|
||||
Here is a list of currently available services that can be used in Q-Apps:
|
||||
|
||||
### Public services ###
|
||||
The services below are intended to be used for publicly accessible data.
|
||||
|
||||
IMAGE,
|
||||
THUMBNAIL,
|
||||
VIDEO,
|
||||
AUDIO,
|
||||
PODCAST,
|
||||
VOICE,
|
||||
ARBITRARY_DATA,
|
||||
JSON,
|
||||
DOCUMENT,
|
||||
LIST,
|
||||
PLAYLIST,
|
||||
METADATA,
|
||||
BLOG,
|
||||
BLOG_POST,
|
||||
BLOG_COMMENT,
|
||||
GIF_REPOSITORY,
|
||||
ATTACHMENT,
|
||||
FILE,
|
||||
FILES,
|
||||
CHAIN_DATA,
|
||||
STORE,
|
||||
PRODUCT,
|
||||
OFFER,
|
||||
COUPON,
|
||||
CODE,
|
||||
PLUGIN,
|
||||
EXTENSION,
|
||||
GAME,
|
||||
ITEM,
|
||||
NFT,
|
||||
DATABASE,
|
||||
SNAPSHOT,
|
||||
COMMENT,
|
||||
CHAIN_COMMENT,
|
||||
WEBSITE,
|
||||
APP,
|
||||
QCHAT_ATTACHMENT,
|
||||
QCHAT_IMAGE,
|
||||
QCHAT_AUDIO,
|
||||
QCHAT_VOICE
|
||||
|
||||
### Private services ###
|
||||
For the services below, data is encrypted for a single recipient, and can only be decrypted using the private key of the recipient's wallet.
|
||||
|
||||
QCHAT_ATTACHMENT_PRIVATE,
|
||||
ATTACHMENT_PRIVATE,
|
||||
FILE_PRIVATE,
|
||||
IMAGE_PRIVATE,
|
||||
VIDEO_PRIVATE,
|
||||
AUDIO_PRIVATE,
|
||||
VOICE_PRIVATE,
|
||||
DOCUMENT_PRIVATE,
|
||||
MAIL_PRIVATE,
|
||||
MESSAGE_PRIVATE
|
||||
|
||||
|
||||
## Single vs multi-file resources
|
||||
|
||||
Some resources, such as those published with the `IMAGE` or `JSON` service, consist of a single file or piece of data (the image or the JSON string). This is the most common type of QDN resource, especially in the context of Q-Apps. These can be published by supplying a base64-encoded string containing the data.
|
||||
|
||||
Other resources, such as those published with the `WEBSITE`, `APP`, or `GIF_REPOSITORY` service, can contain multiple files and directories. Publishing these kinds of files is not yet available for Q-Apps, however it is possible to retrieve multi-file resources that are already published. When retrieving this data (via FETCH_QDN_RESOURCE), a `filepath` must be included to indicate the file that you would like to retrieve. There is no need to specify a filepath for single file resources, as these will automatically return the contents of the single file.
|
||||
|
||||
|
||||
## App-specific data
|
||||
|
||||
Some apps may want to make all QDN data for a particular service available. However, others may prefer to only deal with data that has been published by their app (if a specific format/schema is being used for instance).
|
||||
|
||||
Identifiers can be used to allow app developers to locate data that has been published by their app. The recommended approach for this is to use the app name as a prefix on all identifiers when publishing data.
|
||||
|
||||
For instance, an app called `MyApp` could allow users to publish JSON data. The app could choose to prefix all identifiers with the string `myapp_`, and then use a random string for each published resource (resulting in identifiers such as `myapp_qR5ndZ8v`). Then, to locate data that has potentially been published by users of MyApp, it can later search the QDN database for items with `"service": "JSON"` and `"identifier": "myapp_"`. The SEARCH_QDN_RESOURCES action has a `prefix` option in order to match identifiers beginning with the supplied string.
|
||||
|
||||
Note that QDN is a permissionless system, and therefore it's not possible to verify that a resource was actually published by the app. It is recommended that apps validate the contents of the resource to ensure it is formatted correctly, instead of making assumptions.
|
||||
|
||||
|
||||
## Updating a resource
|
||||
|
||||
To update a resource, it can be overwritten by publishing with the same `name`, `service`, and `identifier` combination. Note that the authenticated account must currently own the name in order to publish an update.
|
||||
|
||||
|
||||
## Routing
|
||||
|
||||
If a non-existent `filepath` is accessed, the default behaviour of QDN is to return a `404: File not found` error. This includes anything published using the `WEBSITE` service.
|
||||
|
||||
However, routing is handled differently for anything published using the `APP` service.
|
||||
|
||||
For apps, QDN automatically sends all unhandled requests to the index file (generally index.html). This allows the app to use custom routing, as it is able to listen on any path. If a file exists at a path, the file itself will be served, and so the request won't be sent to the index file.
|
||||
|
||||
It's recommended that all apps return a 404 page if a request isn't able to be routed.
|
||||
|
||||
|
||||
# Section 1: Simple links and image loading via HTML
|
||||
|
||||
## Section 1a: Linking to other QDN websites / resources
|
||||
|
||||
The `qortal://` protocol can be used to access QDN data from within Qortal websites and apps. The basic format is as follows:
|
||||
```
|
||||
<a href="qortal://{service}/{name}/{identifier}/{path}">link text</a>
|
||||
```
|
||||
|
||||
However, the system will support the omission of the `identifier` and/or `path` components to allow for simpler URL formats.
|
||||
|
||||
A simple link to another website can be achieved with this HTML code:
|
||||
```
|
||||
<a href="qortal://WEBSITE/QortalDemo">link text</a>
|
||||
```
|
||||
|
||||
To link to a specific page of another website:
|
||||
```
|
||||
<a href="qortal://WEBSITE/QortalDemo/minting-leveling/index.html">link text</a>
|
||||
```
|
||||
|
||||
To link to a standalone resource, such as an avatar
|
||||
```
|
||||
<a href="qortal://THUMBNAIL/QortalDemo/qortal_avatar">avatar</a>
|
||||
```
|
||||
|
||||
For cases where you would prefer to explicitly include an identifier (to remove ambiguity) you can use the keyword `default` to access a resource that doesn't have an identifier. For instance:
|
||||
```
|
||||
<a href="qortal://WEBSITE/QortalDemo/default">link to root of website</a>
|
||||
<a href="qortal://WEBSITE/QortalDemo/default/minting-leveling/index.html">link to subpage of website</a>
|
||||
```
|
||||
|
||||
|
||||
## Section 1b: Linking to other QDN images
|
||||
|
||||
The same applies for images, such as displaying an avatar:
|
||||
```
|
||||
<img src="qortal://THUMBNAIL/QortalDemo/qortal_avatar" />
|
||||
```
|
||||
|
||||
...or even an image from an entirely different website:
|
||||
```
|
||||
<img src="qortal://WEBSITE/AlphaX/assets/img/logo.png" />
|
||||
```
|
||||
|
||||
|
||||
# Section 2: Integrating a Javascript app
|
||||
|
||||
Javascript apps allow for much more complex integrations with Qortal's blockchain data.
|
||||
|
||||
## Section 2a: Direct API calls
|
||||
|
||||
The standard [Qortal Core API](http://localhost:12391/api-documentation) is available to websites and apps, and can be called directly using a standard AJAX request, such as:
|
||||
```
|
||||
async function getNameInfo(name) {
|
||||
const response = await fetch("/names/" + name);
|
||||
const nameData = await response.json();
|
||||
console.log("nameData: " + JSON.stringify(nameData));
|
||||
}
|
||||
getNameInfo("QortalDemo");
|
||||
```
|
||||
|
||||
However, this only works for read-only data, such as looking up transactions, names, balances, etc. Also, since the address of the logged in account can't be retrieved from the core, apps can't show personalized data with this approach.
|
||||
|
||||
|
||||
## Section 2b: User interaction via qortalRequest()
|
||||
|
||||
To take things a step further, the qortalRequest() function can be used to interact with the user, in order to:
|
||||
|
||||
- Request address and public key of the logged in account
|
||||
- Publish data to QDN
|
||||
- Send chat messages
|
||||
- Join groups
|
||||
- Deploy ATs (smart contracts)
|
||||
- Send QORT or any supported foreign coin
|
||||
- Add/remove items from lists
|
||||
|
||||
In addition to the above, qortalRequest() also supports many read-only functions that are also available via direct core API calls. Using qortalRequest() helps with futureproofing, as the core APIs can be modified without breaking functionality of existing Q-Apps.
|
||||
|
||||
|
||||
### Making a request
|
||||
|
||||
Qortal core will automatically inject the `qortalRequest()` javascript function to all websites/apps, which returns a Promise. This can be used to fetch data or publish data to 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
|
||||
|
||||
Request timeouts are handled automatically when using qortalRequest(). The timeout value will differ based on the action being used - see `getDefaultTimeout()` in [q-apps.js](src/main/resources/q-apps/q-apps.js) for the current values.
|
||||
|
||||
If a request times out it will throw an error - `The request timed out` - which can be handled by the Q-App.
|
||||
|
||||
It is also possible to specify a custom timeout using `qortalRequestWithTimeout(request, timeout)`, however this is discouraged. It's more reliable and futureproof to let the core handle the timeout values.
|
||||
|
||||
|
||||
# Section 3: qortalRequest Documentation
|
||||
|
||||
## Supported actions
|
||||
|
||||
Here is a list of currently supported actions:
|
||||
- GET_USER_ACCOUNT
|
||||
- GET_ACCOUNT_DATA
|
||||
- GET_ACCOUNT_NAMES
|
||||
- GET_NAME_DATA
|
||||
- LIST_QDN_RESOURCES
|
||||
- SEARCH_QDN_RESOURCES
|
||||
- GET_QDN_RESOURCE_STATUS
|
||||
- GET_QDN_RESOURCE_PROPERTIES
|
||||
- GET_QDN_RESOURCE_METADATA
|
||||
- GET_QDN_RESOURCE_URL
|
||||
- LINK_TO_QDN_RESOURCE
|
||||
- FETCH_QDN_RESOURCE
|
||||
- PUBLISH_QDN_RESOURCE
|
||||
- PUBLISH_MULTIPLE_QDN_RESOURCES
|
||||
- DECRYPT_DATA
|
||||
- SAVE_FILE
|
||||
- GET_WALLET_BALANCE
|
||||
- GET_BALANCE
|
||||
- SEND_COIN
|
||||
- SEARCH_CHAT_MESSAGES
|
||||
- SEND_CHAT_MESSAGE
|
||||
- LIST_GROUPS
|
||||
- JOIN_GROUP
|
||||
- DEPLOY_AT
|
||||
- GET_AT
|
||||
- GET_AT_DATA
|
||||
- LIST_ATS
|
||||
- FETCH_BLOCK
|
||||
- FETCH_BLOCK_RANGE
|
||||
- SEARCH_TRANSACTIONS
|
||||
- GET_PRICE
|
||||
- GET_LIST_ITEMS
|
||||
- ADD_LIST_ITEMS
|
||||
- DELETE_LIST_ITEM
|
||||
|
||||
More functionality will be added in the future.
|
||||
|
||||
## Example Requests
|
||||
|
||||
Here are some example requests for each of the above:
|
||||
|
||||
### Get address of logged in account
|
||||
_Will likely require user approval_
|
||||
```
|
||||
let account = await qortalRequest({
|
||||
action: "GET_USER_ACCOUNT"
|
||||
});
|
||||
let address = account.address;
|
||||
```
|
||||
|
||||
### Get public key of logged in account
|
||||
_Will likely require user approval_
|
||||
```
|
||||
let pubkey = await qortalRequest({
|
||||
action: "GET_USER_ACCOUNT"
|
||||
});
|
||||
let publicKey = account.publicKey;
|
||||
```
|
||||
|
||||
### 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"
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
### List QDN resources
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "LIST_QDN_RESOURCES",
|
||||
service: "THUMBNAIL",
|
||||
name: "QortalDemo", // Optional (exact match)
|
||||
identifier: "qortal_avatar", // Optional (exact match)
|
||||
default: true, // Optional
|
||||
includeStatus: false, // Optional - will take time to respond, so only request if necessary
|
||||
includeMetadata: false, // Optional - will take time to respond, so only request if necessary
|
||||
followedOnly: false, // Optional - include followed names only
|
||||
excludeBlocked: false, // Optional - exclude blocked content
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
reverse: true
|
||||
});
|
||||
```
|
||||
|
||||
### Search QDN resources
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "SEARCH_QDN_RESOURCES",
|
||||
service: "THUMBNAIL",
|
||||
query: "search query goes here", // Optional - searches both "identifier" and "name" fields
|
||||
identifier: "search query goes here", // Optional - searches only the "identifier" field
|
||||
name: "search query goes here", // Optional - searches only the "name" field
|
||||
prefix: false, // Optional - if true, only the beginning of fields are matched in all of the above filters
|
||||
exactMatchNames: true, // Optional - if true, partial name matches are excluded
|
||||
default: false, // Optional - if true, only resources without identifiers are returned
|
||||
includeStatus: false, // Optional - will take time to respond, so only request if necessary
|
||||
includeMetadata: false, // Optional - will take time to respond, so only request if necessary
|
||||
nameListFilter: "QApp1234Subscriptions", // Optional - will only return results if they are from a name included in supplied list
|
||||
followedOnly: false, // Optional - include followed names only
|
||||
excludeBlocked: false, // Optional - exclude blocked content
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
reverse: true
|
||||
});
|
||||
```
|
||||
|
||||
### Search QDN resources (multiple names)
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "SEARCH_QDN_RESOURCES",
|
||||
service: "THUMBNAIL",
|
||||
query: "search query goes here", // Optional - searches both "identifier" and "name" fields
|
||||
identifier: "search query goes here", // Optional - searches only the "identifier" field
|
||||
names: ["QortalDemo", "crowetic", "AlphaX"], // Optional - searches only the "name" field for any of the supplied names
|
||||
prefix: false, // Optional - if true, only the beginning of fields are matched in all of the above filters
|
||||
default: false, // Optional - if true, only resources without identifiers are returned
|
||||
includeStatus: false, // Optional - will take time to respond, so only request if necessary
|
||||
includeMetadata: false, // Optional - will take time to respond, so only request if necessary
|
||||
nameListFilter: "QApp1234Subscriptions", // Optional - will only return results if they are from a name included in supplied list
|
||||
followedOnly: false, // Optional - include followed names only
|
||||
excludeBlocked: false, // Optional - exclude blocked content
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
reverse: true
|
||||
});
|
||||
```
|
||||
|
||||
### Fetch QDN single file resource
|
||||
```
|
||||
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"
|
||||
encoding: "base64", // Optional. If omitted, data is returned in raw form
|
||||
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
|
||||
build: true // Optional - request that the resource is fetched & built in the background
|
||||
});
|
||||
```
|
||||
|
||||
### Get QDN resource properties
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_PROPERTIES",
|
||||
name: "QortalDemo",
|
||||
service: "THUMBNAIL",
|
||||
identifier: "qortal_avatar" // Optional
|
||||
});
|
||||
// Returns: filename, size, mimeType (where available)
|
||||
```
|
||||
|
||||
### Get QDN resource metadata
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_METADATA",
|
||||
name: "QortalDemo",
|
||||
service: "THUMBNAIL",
|
||||
identifier: "qortal_avatar" // Optional
|
||||
});
|
||||
```
|
||||
|
||||
### Publish a single file to QDN
|
||||
_Requires user approval_.<br />
|
||||
Note: this publishes a single, base64-encoded file. Multi-file resource publishing (such as a WEBSITE or GIF_REPOSITORY) is not yet supported via a Q-App. It will be added in a future update.
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "PUBLISH_QDN_RESOURCE",
|
||||
name: "Demo", // Publisher must own the registered name - use GET_ACCOUNT_NAMES for a list
|
||||
service: "IMAGE",
|
||||
identifier: "myapp-image1234" // Optional
|
||||
data64: "base64_encoded_data",
|
||||
// filename: "image.jpg", // Optional - to help apps determine the file's type
|
||||
// title: "Title", // Optional
|
||||
// description: "Description", // Optional
|
||||
// category: "TECHNOLOGY", // Optional
|
||||
// tag1: "any", // Optional
|
||||
// tag2: "strings", // Optional
|
||||
// tag3: "can", // Optional
|
||||
// tag4: "go", // Optional
|
||||
// tag5: "here", // Optional
|
||||
// encrypt: true, // Optional - to be used with a private service
|
||||
// recipientPublicKey: "publickeygoeshere" // Only required if `encrypt` is set to true
|
||||
});
|
||||
```
|
||||
|
||||
### Publish multiple resources at once to QDN
|
||||
_Requires user approval_.<br />
|
||||
Note: each resource being published consists of a single, base64-encoded file, each in its own transaction. Useful for publishing two or more related things, such as a video and a video thumbnail.
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "PUBLISH_MULTIPLE_QDN_RESOURCES",
|
||||
resources: [
|
||||
name: "Demo", // Publisher must own the registered name - use GET_ACCOUNT_NAMES for a list
|
||||
service: "IMAGE",
|
||||
identifier: "myapp-image1234" // Optional
|
||||
data64: "base64_encoded_data",
|
||||
// filename: "image.jpg", // Optional - to help apps determine the file's type
|
||||
// title: "Title", // Optional
|
||||
// description: "Description", // Optional
|
||||
// category: "TECHNOLOGY", // Optional
|
||||
// tag1: "any", // Optional
|
||||
// tag2: "strings", // Optional
|
||||
// tag3: "can", // Optional
|
||||
// tag4: "go", // Optional
|
||||
// tag5: "here", // Optional
|
||||
// encrypt: true, // Optional - to be used with a private service
|
||||
// recipientPublicKey: "publickeygoeshere" // Only required if `encrypt` is set to true
|
||||
],
|
||||
[
|
||||
... more resources here if needed ...
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Decrypt encrypted/private data
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "DECRYPT_DATA",
|
||||
encryptedData: 'qortalEncryptedDatabMx4fELNTV+ifJxmv4+GcuOIJOTo+3qAvbWKNY2L1r',
|
||||
publicKey: 'publickeygoeshere'
|
||||
});
|
||||
// Returns base64 encoded string of plaintext data
|
||||
```
|
||||
|
||||
### Prompt user to save a file to disk
|
||||
Note: mimeType not required but recommended. If not specified, saving will fail if the mimeType is unable to be derived from the Blob.
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "SAVE_FILE",
|
||||
blob: dataBlob,
|
||||
filename: "myfile.pdf",
|
||||
mimeType: "application/pdf" // Optional but recommended
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
### Get wallet balance (QORT)
|
||||
_Requires user approval_
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "GET_WALLET_BALANCE",
|
||||
coin: "QORT"
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
### 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 QORT to address
|
||||
_Requires user approval_
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "SEND_COIN",
|
||||
coin: "QORT",
|
||||
destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2",
|
||||
amount: 1.00000000 // 1 QORT
|
||||
});
|
||||
```
|
||||
|
||||
### Send foreign coin to address
|
||||
_Requires user approval_
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "SEND_COIN",
|
||||
coin: "LTC",
|
||||
destinationAddress: "LSdTvMHRm8sScqwCi6x9wzYQae8JeZhx6y",
|
||||
amount: 1.00000000, // 1 LTC
|
||||
fee: 0.00000020 // fee 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
|
||||
encoding: "BASE64", // Optional (defaults to BASE58 if omitted)
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
reverse: true
|
||||
});
|
||||
```
|
||||
|
||||
### Send a group chat message
|
||||
_Requires user approval_
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "SEND_CHAT_MESSAGE",
|
||||
groupId: 0,
|
||||
message: "Test"
|
||||
});
|
||||
```
|
||||
|
||||
### Send a private chat message
|
||||
_Requires user approval_
|
||||
```
|
||||
let res = 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_
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "JOIN_GROUP",
|
||||
groupId: 100
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
### Deploy an AT
|
||||
_Requires user approval_
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "DEPLOY_AT",
|
||||
creationBytes: "12345", // Must be Base58 encoded
|
||||
name: "test name",
|
||||
description: "test description",
|
||||
type: "test type",
|
||||
tags: "test tags",
|
||||
amount: 1.00000000, // 1 QORT
|
||||
assetId: 0,
|
||||
// fee: 0.002 // optional - will use default fee if excluded
|
||||
});
|
||||
```
|
||||
|
||||
### 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
|
||||
});
|
||||
```
|
||||
|
||||
### Fetch block by signature
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "FETCH_BLOCK",
|
||||
signature: "875yGFUy1zHV2hmxNWzrhtn9S1zkeD7SQppwdXFysvTXrankCHCz4iyAUgCBM3GjvibbnyRQpriuy1cyu953U1u5uQdzuH3QjQivi9UVwz86z1Akn17MGd5Z5STjpDT7248K6vzMamuqDei57Znonr8GGgn8yyyABn35CbZUCeAuXju"
|
||||
});
|
||||
```
|
||||
|
||||
### Fetch block by height
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "FETCH_BLOCK",
|
||||
height: "1139850"
|
||||
});
|
||||
```
|
||||
|
||||
### Fetch a range of blocks
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "FETCH_BLOCK_RANGE",
|
||||
height: "1139800",
|
||||
count: 20,
|
||||
reverse: false
|
||||
});
|
||||
```
|
||||
|
||||
### Search transactions
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "SEARCH_TRANSACTIONS",
|
||||
// startBlock: 1139000,
|
||||
// blockLimit: 1000,
|
||||
txGroupId: 0,
|
||||
txType: [
|
||||
"PAYMENT",
|
||||
"REWARD_SHARE"
|
||||
],
|
||||
confirmationStatus: "CONFIRMED",
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
reverse: false
|
||||
});
|
||||
```
|
||||
|
||||
### Get an estimate of the QORT price
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "GET_PRICE",
|
||||
blockchain: "LITECOIN",
|
||||
// maxtrades: 10,
|
||||
inverse: true
|
||||
});
|
||||
```
|
||||
|
||||
### Get URL to load a QDN resource
|
||||
Note: this returns a "Resource does not exist" error if a non-existent resource is requested.
|
||||
```
|
||||
let url = await qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_URL",
|
||||
service: "THUMBNAIL",
|
||||
name: "QortalDemo",
|
||||
identifier: "qortal_avatar"
|
||||
// path: "filename.jpg" // optional - not needed if resource contains only one file
|
||||
});
|
||||
```
|
||||
|
||||
### Get URL to load a QDN website
|
||||
Note: this returns a "Resource does not exist" error if a non-existent resource is requested.
|
||||
```
|
||||
let url = await qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_URL",
|
||||
service: "WEBSITE",
|
||||
name: "QortalDemo",
|
||||
});
|
||||
```
|
||||
|
||||
### Get URL to load a specific file from a QDN website
|
||||
Note: this returns a "Resource does not exist" error if a non-existent resource is requested.
|
||||
```
|
||||
let url = await qortalRequest({
|
||||
action: "GET_QDN_RESOURCE_URL",
|
||||
service: "WEBSITE",
|
||||
name: "AlphaX",
|
||||
path: "/assets/img/logo.png"
|
||||
});
|
||||
```
|
||||
|
||||
### Link/redirect to another QDN website
|
||||
Note: an alternate method is to include `<a href="qortal://WEBSITE/QortalDemo">link text</a>` within your HTML code.
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "LINK_TO_QDN_RESOURCE",
|
||||
service: "WEBSITE",
|
||||
name: "QortalDemo",
|
||||
});
|
||||
```
|
||||
|
||||
### Link/redirect to a specific path of another QDN website
|
||||
Note: an alternate method is to include `<a href="qortal://WEBSITE/QortalDemo/minting-leveling/index.html">link text</a>` within your HTML code.
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "LINK_TO_QDN_RESOURCE",
|
||||
service: "WEBSITE",
|
||||
name: "QortalDemo",
|
||||
path: "/minting-leveling/index.html"
|
||||
});
|
||||
```
|
||||
|
||||
### Get the contents of a list
|
||||
_Requires user approval_
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "GET_LIST_ITEMS",
|
||||
list_name: "followedNames"
|
||||
});
|
||||
```
|
||||
|
||||
### Add one or more items to a list
|
||||
_Requires user approval_
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "ADD_LIST_ITEMS",
|
||||
list_name: "blockedNames",
|
||||
items: ["QortalDemo"]
|
||||
});
|
||||
```
|
||||
|
||||
### Delete a single item from a list
|
||||
_Requires user approval_.
|
||||
Items must be deleted one at a time.
|
||||
```
|
||||
let res = await qortalRequest({
|
||||
action: "DELETE_LIST_ITEM",
|
||||
list_name: "blockedNames",
|
||||
item: "QortalDemo"
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
# Section 4: Examples
|
||||
|
||||
Some example projects can be found [here](https://github.com/Qortal/Q-Apps). These can be cloned and modified, or used as a reference when creating a new app.
|
||||
|
||||
|
||||
## Sample App
|
||||
|
||||
Here is a sample application to display the logged-in user's avatar:
|
||||
```
|
||||
<html>
|
||||
<head>
|
||||
<script>
|
||||
async function showAvatar() {
|
||||
try {
|
||||
// Get QORT address of logged in account
|
||||
let account = await qortalRequest({
|
||||
action: "GET_USER_ACCOUNT"
|
||||
});
|
||||
let address = 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.length == 0) {
|
||||
console.log("User has no registered names");
|
||||
return;
|
||||
}
|
||||
|
||||
// Download base64-encoded avatar of the first registered name
|
||||
let avatar = await qortalRequest({
|
||||
action: "FETCH_QDN_RESOURCE",
|
||||
name: names[0].name,
|
||||
service: "THUMBNAIL",
|
||||
identifier: "qortal_avatar",
|
||||
encoding: "base64"
|
||||
});
|
||||
console.log("Avatar size: " + avatar.length + " bytes");
|
||||
|
||||
// Display the avatar image on the screen
|
||||
document.getElementById("avatar").src = "data:image/png;base64," + avatar;
|
||||
|
||||
} catch(e) {
|
||||
console.log("Error: " + JSON.stringify(e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body onload="showAvatar()">
|
||||
<img width="500" id="avatar" />
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
|
||||
# Section 5: 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
|
||||
|
||||
Select "Preview" in the UI after choosing the zip. This allows for full Q-App testing without the need to publish any data.
|
||||
|
||||
|
||||
### Testnets
|
||||
|
||||
For an end-to-end test of Q-App publishing, you can use the official testnet, or set up a single node testnet of your own (often referred to as devnet) on your local machine. See [Single Node Testnet Quick Start Guide](testnet/README.md#quick-start).
|
||||
|
||||
|
||||
### Debugging
|
||||
|
||||
It is recommended that you develop and test in a web browser, to allow access to the javascript console. To do this:
|
||||
1. Open the UI app, then minimise it.
|
||||
2. In a Chromium-based web browser, visit: http://localhost:12388/
|
||||
3. Log in to your account and then preview your app/website.
|
||||
4. Go to `View > Developer > JavaScript Console`. Here you can monitor console logs, errors, and network requests from your app, in the same way as any other web-app.
|
@@ -17,10 +17,10 @@
|
||||
<ROW Property="Manufacturer" Value="Qortal"/>
|
||||
<ROW Property="MsiLogging" MultiBuildValue="DefaultBuild:vp"/>
|
||||
<ROW Property="NTP_GOOD" Value="false"/>
|
||||
<ROW Property="ProductCode" Value="1033:{ADE0C9E9-F7D9-4829-8626-8571C735C4D7} 1049:{F5230C0A-9D8C-4C70-AC72-17CECC8273B8} 2052:{D5A0760C-E5B3-4C4C-97B0-81CC445F07B9} 2057:{EF5EF0BE-0B00-4F5C-A2A0-DF2CB82FF20D} " Type="16"/>
|
||||
<ROW Property="ProductCode" Value="1033:{CB85115E-ECCE-4B3D-BB7F-6251A2764922} 1049:{09AC1C62-4E33-4312-826A-38F597ED1B17} 2052:{3CF701B3-E118-4A31-A4B7-156CEA19FBCC} 2057:{468F337D-0EF8-41D1-B5DE-4EEE66BA2AF6} " Type="16"/>
|
||||
<ROW Property="ProductLanguage" Value="2057"/>
|
||||
<ROW Property="ProductName" Value="Qortal"/>
|
||||
<ROW Property="ProductVersion" Value="3.6.3" Type="32"/>
|
||||
<ROW Property="ProductVersion" Value="3.8.5" Type="32"/>
|
||||
<ROW Property="RECONFIG_NTP" Value="true"/>
|
||||
<ROW Property="REMOVE_BLOCKCHAIN" Value="YES" Type="4"/>
|
||||
<ROW Property="REPAIR_BLOCKCHAIN" Value="YES" Type="4"/>
|
||||
@@ -212,7 +212,7 @@
|
||||
<ROW Component="ADDITIONAL_LICENSE_INFO_71" ComponentId="{12A3ADBE-BB7A-496C-8869-410681E6232F}" Directory_="jdk.zipfs_Dir" Attributes="0" KeyPath="ADDITIONAL_LICENSE_INFO_71" Type="0"/>
|
||||
<ROW Component="ADDITIONAL_LICENSE_INFO_8" ComponentId="{D53AD95E-CF96-4999-80FC-5812277A7456}" Directory_="java.naming_Dir" Attributes="0" KeyPath="ADDITIONAL_LICENSE_INFO_8" Type="0"/>
|
||||
<ROW Component="ADDITIONAL_LICENSE_INFO_9" ComponentId="{6B7EA9B0-5D17-47A8-B78C-FACE86D15E01}" Directory_="java.net.http_Dir" Attributes="0" KeyPath="ADDITIONAL_LICENSE_INFO_9" Type="0"/>
|
||||
<ROW Component="AI_CustomARPName" ComponentId="{F4F774B9-18DC-4740-9552-EA16B98801C9}" Directory_="APPDIR" Attributes="260" KeyPath="DisplayName" Options="1"/>
|
||||
<ROW Component="AI_CustomARPName" ComponentId="{094B5D07-2258-4A39-9917-2E2F7F6E210B}" Directory_="APPDIR" Attributes="260" KeyPath="DisplayName" Options="1"/>
|
||||
<ROW Component="AI_ExePath" ComponentId="{3644948D-AE0B-41BB-9FAF-A79E70490A08}" Directory_="APPDIR" Attributes="260" KeyPath="AI_ExePath"/>
|
||||
<ROW Component="APPDIR" ComponentId="{680DFDDE-3FB4-47A5-8FF5-934F576C6F91}" Directory_="APPDIR" Attributes="0"/>
|
||||
<ROW Component="AccessBridgeCallbacks.h" ComponentId="{288055D1-1062-47A3-AA44-5601B4E38AED}" Directory_="bridge_Dir" Attributes="0" KeyPath="AccessBridgeCallbacks.h" Type="0"/>
|
||||
|
10
pom.xml
10
pom.xml
@@ -3,7 +3,7 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>org.qortal</groupId>
|
||||
<artifactId>qortal</artifactId>
|
||||
<version>3.8.2</version>
|
||||
<version>4.0.3</version>
|
||||
<packaging>jar</packaging>
|
||||
<properties>
|
||||
<skipTests>true</skipTests>
|
||||
@@ -36,6 +36,7 @@
|
||||
<java-diff-utils.version>4.10</java-diff-utils.version>
|
||||
<grpc.version>1.45.1</grpc.version>
|
||||
<protobuf.version>3.19.4</protobuf.version>
|
||||
<simplemagic.version>1.17</simplemagic.version>
|
||||
</properties>
|
||||
<build>
|
||||
<sourceDirectory>src/main/java</sourceDirectory>
|
||||
@@ -147,6 +148,7 @@
|
||||
tagsSorter: "alpha",
|
||||
operationsSorter:
|
||||
"alpha",
|
||||
validatorUrl: false,
|
||||
</value>
|
||||
</replacement>
|
||||
</replacements>
|
||||
@@ -304,6 +306,7 @@
|
||||
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>org.qortal.controller.Controller</mainClass>
|
||||
<manifestEntries>
|
||||
<Multi-Release>true</Multi-Release>
|
||||
<Class-Path>. ..</Class-Path>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
@@ -727,5 +730,10 @@
|
||||
<artifactId>protobuf-java</artifactId>
|
||||
<version>${protobuf.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.j256.simplemagic</groupId>
|
||||
<artifactId>simplemagic</artifactId>
|
||||
<version>${simplemagic.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
@@ -79,7 +79,7 @@ public enum ApiError {
|
||||
// BUYER_ALREADY_OWNER(411, 422),
|
||||
|
||||
// POLLS
|
||||
// POLL_NO_EXISTS(501, 404),
|
||||
POLL_NO_EXISTS(501, 404),
|
||||
// POLL_ALREADY_EXISTS(502, 422),
|
||||
// DUPLICATE_OPTION(503, 422),
|
||||
// POLL_OPTION_NO_EXISTS(504, 404),
|
||||
|
@@ -3,6 +3,7 @@ package org.qortal.api;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.io.Writer;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
@@ -20,14 +21,12 @@ import javax.net.ssl.SNIHostName;
|
||||
import javax.net.ssl.SNIServerName;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.xml.bind.JAXBContext;
|
||||
import javax.xml.bind.JAXBException;
|
||||
import javax.xml.bind.UnmarshalException;
|
||||
import javax.xml.bind.Unmarshaller;
|
||||
import javax.xml.bind.*;
|
||||
import javax.xml.transform.stream.StreamSource;
|
||||
|
||||
import org.eclipse.persistence.exceptions.XMLMarshalException;
|
||||
import org.eclipse.persistence.jaxb.JAXBContextFactory;
|
||||
import org.eclipse.persistence.jaxb.MarshallerProperties;
|
||||
import org.eclipse.persistence.jaxb.UnmarshallerProperties;
|
||||
|
||||
public class ApiRequest {
|
||||
@@ -107,6 +106,36 @@ public class ApiRequest {
|
||||
}
|
||||
}
|
||||
|
||||
private static Marshaller createMarshaller(Class<?> objectClass) {
|
||||
try {
|
||||
// Create JAXB context aware of object's class
|
||||
JAXBContext jc = JAXBContextFactory.createContext(new Class[] { objectClass }, null);
|
||||
|
||||
// Create marshaller
|
||||
Marshaller marshaller = jc.createMarshaller();
|
||||
|
||||
// Set the marshaller media type to JSON
|
||||
marshaller.setProperty(MarshallerProperties.MEDIA_TYPE, "application/json");
|
||||
|
||||
// Tell marshaller not to include JSON root element in the output
|
||||
marshaller.setProperty(MarshallerProperties.JSON_INCLUDE_ROOT, false);
|
||||
|
||||
return marshaller;
|
||||
} catch (JAXBException e) {
|
||||
throw new RuntimeException("Unable to create API marshaller", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void marshall(Writer writer, Object object) throws IOException {
|
||||
Marshaller marshaller = createMarshaller(object.getClass());
|
||||
|
||||
try {
|
||||
marshaller.marshal(object, writer);
|
||||
} catch (JAXBException e) {
|
||||
throw new IOException("Unable to create marshall object for API", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String getParamsString(Map<String, String> params) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
|
||||
|
@@ -13,8 +13,8 @@ import java.security.SecureRandom;
|
||||
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.checkerframework.checker.units.qual.A;
|
||||
import org.eclipse.jetty.http.HttpVersion;
|
||||
import org.eclipse.jetty.rewrite.handler.RedirectPatternRule;
|
||||
import org.eclipse.jetty.rewrite.handler.RewriteHandler;
|
||||
@@ -41,6 +41,7 @@ import org.glassfish.jersey.servlet.ServletContainer;
|
||||
import org.qortal.api.resource.AnnotationPostProcessor;
|
||||
import org.qortal.api.resource.ApiDefinition;
|
||||
import org.qortal.api.websocket.*;
|
||||
import org.qortal.network.Network;
|
||||
import org.qortal.settings.Settings;
|
||||
|
||||
public class ApiService {
|
||||
@@ -51,9 +52,11 @@ public class ApiService {
|
||||
private Server server;
|
||||
private ApiKey apiKey;
|
||||
|
||||
public static final String API_VERSION_HEADER = "X-API-VERSION";
|
||||
|
||||
private ApiService() {
|
||||
this.config = new ResourceConfig();
|
||||
this.config.packages("org.qortal.api.resource");
|
||||
this.config.packages("org.qortal.api.resource", "org.qortal.api.restricted.resource");
|
||||
this.config.register(OpenApiResource.class);
|
||||
this.config.register(ApiDefinition.class);
|
||||
this.config.register(AnnotationPostProcessor.class);
|
||||
@@ -123,13 +126,13 @@ public class ApiService {
|
||||
ServerConnector portUnifiedConnector = new ServerConnector(this.server,
|
||||
new DetectorConnectionFactory(sslConnectionFactory),
|
||||
httpConnectionFactory);
|
||||
portUnifiedConnector.setHost(Settings.getInstance().getBindAddress());
|
||||
portUnifiedConnector.setHost(Network.getInstance().getBindAddress());
|
||||
portUnifiedConnector.setPort(Settings.getInstance().getApiPort());
|
||||
|
||||
this.server.addConnector(portUnifiedConnector);
|
||||
} else {
|
||||
// Non-SSL
|
||||
InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress());
|
||||
InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress());
|
||||
InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getApiPort());
|
||||
this.server = new Server(endpoint);
|
||||
}
|
||||
@@ -230,4 +233,19 @@ public class ApiService {
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
public static int getApiVersion(HttpServletRequest request) {
|
||||
// Get API version
|
||||
String apiVersionString = request.getHeader(API_VERSION_HEADER);
|
||||
if (apiVersionString == null) {
|
||||
// Try query string - this is needed to avoid a CORS preflight. See: https://stackoverflow.com/a/43881141
|
||||
apiVersionString = request.getParameter("apiVersion");
|
||||
}
|
||||
|
||||
int apiVersion = 1;
|
||||
if (apiVersionString != null) {
|
||||
apiVersion = Integer.parseInt(apiVersionString);
|
||||
}
|
||||
return apiVersion;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -3,7 +3,6 @@ package org.qortal.api;
|
||||
import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource;
|
||||
import org.eclipse.jetty.http.HttpVersion;
|
||||
import org.eclipse.jetty.rewrite.handler.RewriteHandler;
|
||||
import org.eclipse.jetty.rewrite.handler.RewritePatternRule;
|
||||
import org.eclipse.jetty.server.*;
|
||||
import org.eclipse.jetty.server.handler.ErrorHandler;
|
||||
import org.eclipse.jetty.server.handler.InetAccessHandler;
|
||||
@@ -16,6 +15,7 @@ import org.glassfish.jersey.server.ResourceConfig;
|
||||
import org.glassfish.jersey.servlet.ServletContainer;
|
||||
import org.qortal.api.resource.AnnotationPostProcessor;
|
||||
import org.qortal.api.resource.ApiDefinition;
|
||||
import org.qortal.network.Network;
|
||||
import org.qortal.settings.Settings;
|
||||
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
@@ -38,7 +38,7 @@ public class DomainMapService {
|
||||
|
||||
private DomainMapService() {
|
||||
this.config = new ResourceConfig();
|
||||
this.config.packages("org.qortal.api.domainmap.resource");
|
||||
this.config.packages("org.qortal.api.resource", "org.qortal.api.domainmap.resource");
|
||||
this.config.register(OpenApiResource.class);
|
||||
this.config.register(ApiDefinition.class);
|
||||
this.config.register(AnnotationPostProcessor.class);
|
||||
@@ -99,13 +99,13 @@ public class DomainMapService {
|
||||
ServerConnector portUnifiedConnector = new ServerConnector(this.server,
|
||||
new DetectorConnectionFactory(sslConnectionFactory),
|
||||
httpConnectionFactory);
|
||||
portUnifiedConnector.setHost(Settings.getInstance().getBindAddress());
|
||||
portUnifiedConnector.setHost(Network.getInstance().getBindAddress());
|
||||
portUnifiedConnector.setPort(Settings.getInstance().getDomainMapPort());
|
||||
|
||||
this.server.addConnector(portUnifiedConnector);
|
||||
} else {
|
||||
// Non-SSL
|
||||
InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress());
|
||||
InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress());
|
||||
InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getDomainMapPort());
|
||||
this.server = new Server(endpoint);
|
||||
}
|
||||
|
@@ -15,6 +15,7 @@ import org.glassfish.jersey.server.ResourceConfig;
|
||||
import org.glassfish.jersey.servlet.ServletContainer;
|
||||
import org.qortal.api.resource.AnnotationPostProcessor;
|
||||
import org.qortal.api.resource.ApiDefinition;
|
||||
import org.qortal.network.Network;
|
||||
import org.qortal.settings.Settings;
|
||||
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
@@ -37,7 +38,7 @@ public class GatewayService {
|
||||
|
||||
private GatewayService() {
|
||||
this.config = new ResourceConfig();
|
||||
this.config.packages("org.qortal.api.gateway.resource");
|
||||
this.config.packages("org.qortal.api.resource", "org.qortal.api.gateway.resource");
|
||||
this.config.register(OpenApiResource.class);
|
||||
this.config.register(ApiDefinition.class);
|
||||
this.config.register(AnnotationPostProcessor.class);
|
||||
@@ -98,13 +99,13 @@ public class GatewayService {
|
||||
ServerConnector portUnifiedConnector = new ServerConnector(this.server,
|
||||
new DetectorConnectionFactory(sslConnectionFactory),
|
||||
httpConnectionFactory);
|
||||
portUnifiedConnector.setHost(Settings.getInstance().getBindAddress());
|
||||
portUnifiedConnector.setHost(Network.getInstance().getBindAddress());
|
||||
portUnifiedConnector.setPort(Settings.getInstance().getGatewayPort());
|
||||
|
||||
this.server.addConnector(portUnifiedConnector);
|
||||
} else {
|
||||
// Non-SSL
|
||||
InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress());
|
||||
InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress());
|
||||
InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getGatewayPort());
|
||||
this.server = new Server(endpoint);
|
||||
}
|
||||
|
@@ -5,28 +5,71 @@ import org.apache.logging.log4j.Logger;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.select.Elements;
|
||||
import org.qortal.arbitrary.misc.Service;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class HTMLParser {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(HTMLParser.class);
|
||||
|
||||
private String linkPrefix;
|
||||
private String qdnBase;
|
||||
private String qdnBaseWithPath;
|
||||
private byte[] data;
|
||||
private String qdnContext;
|
||||
private String resourceId;
|
||||
private Service service;
|
||||
private String identifier;
|
||||
private String path;
|
||||
private String theme;
|
||||
private boolean usingCustomRouting;
|
||||
|
||||
public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data) {
|
||||
String inPathWithoutFilename = inPath.substring(0, inPath.lastIndexOf('/'));
|
||||
this.linkPrefix = usePrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : "";
|
||||
public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data,
|
||||
String qdnContext, Service service, String identifier, String theme, boolean usingCustomRouting) {
|
||||
String inPathWithoutFilename = inPath.contains("/") ? inPath.substring(0, inPath.lastIndexOf('/')) : "";
|
||||
this.qdnBase = usePrefix ? String.format("%s/%s", prefix, resourceId) : "";
|
||||
this.qdnBaseWithPath = usePrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : "";
|
||||
this.data = data;
|
||||
this.qdnContext = qdnContext;
|
||||
this.resourceId = resourceId;
|
||||
this.service = service;
|
||||
this.identifier = identifier;
|
||||
this.path = inPath;
|
||||
this.theme = theme;
|
||||
this.usingCustomRouting = usingCustomRouting;
|
||||
}
|
||||
|
||||
public void addAdditionalHeaderTags() {
|
||||
String fileContents = new String(data);
|
||||
Document document = Jsoup.parse(fileContents);
|
||||
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 q-apps gateway script tag if in gateway mode
|
||||
if (Objects.equals(this.qdnContext, "gateway")) {
|
||||
String qAppsGatewayScriptElement = String.format("<script src=\"/apps/q-apps-gateway.js?time=%d\">", System.currentTimeMillis());
|
||||
head.get(0).prepend(qAppsGatewayScriptElement);
|
||||
}
|
||||
|
||||
// Escape and add vars
|
||||
String qdnContext = this.qdnContext != null ? this.qdnContext.replace("\\", "").replace("\"","\\\"") : "";
|
||||
String service = this.service.toString().replace("\\", "").replace("\"","\\\"");
|
||||
String name = this.resourceId != null ? this.resourceId.replace("\\", "").replace("\"","\\\"") : "";
|
||||
String identifier = this.identifier != null ? this.identifier.replace("\\", "").replace("\"","\\\"") : "";
|
||||
String path = this.path != null ? this.path.replace("\\", "").replace("\"","\\\"") : "";
|
||||
String theme = this.theme != null ? this.theme.replace("\\", "").replace("\"","\\\"") : "";
|
||||
String qdnBase = this.qdnBase != null ? this.qdnBase.replace("\\", "").replace("\"","\\\"") : "";
|
||||
String qdnBaseWithPath = this.qdnBaseWithPath != null ? this.qdnBaseWithPath.replace("\\", "").replace("\"","\\\"") : "";
|
||||
String qdnContextVar = String.format("<script>var _qdnContext=\"%s\"; var _qdnTheme=\"%s\"; var _qdnService=\"%s\"; var _qdnName=\"%s\"; var _qdnIdentifier=\"%s\"; var _qdnPath=\"%s\"; var _qdnBase=\"%s\"; var _qdnBaseWithPath=\"%s\";</script>", qdnContext, theme, service, name, identifier, path, qdnBase, qdnBaseWithPath);
|
||||
head.get(0).prepend(qdnContextVar);
|
||||
|
||||
// Add base href tag
|
||||
String baseElement = String.format("<base href=\"%s\">", baseUrl);
|
||||
// Exclude the path if this request was routed back to the index automatically
|
||||
String baseHref = this.usingCustomRouting ? this.qdnBase : this.qdnBaseWithPath;
|
||||
String baseElement = String.format("<base href=\"%s/\">", baseHref);
|
||||
head.get(0).prepend(baseElement);
|
||||
|
||||
// Add meta charset tag
|
||||
|
@@ -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");
|
||||
@@ -56,7 +73,7 @@ public abstract class Security {
|
||||
public static void disallowLoopbackRequests(HttpServletRequest request) {
|
||||
try {
|
||||
InetAddress remoteAddr = InetAddress.getByName(request.getRemoteAddr());
|
||||
if (remoteAddr.isLoopbackAddress()) {
|
||||
if (remoteAddr.isLoopbackAddress() && !Settings.getInstance().isGatewayLoopbackEnabled()) {
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "Local requests not allowed");
|
||||
}
|
||||
} catch (UnknownHostException e) {
|
||||
@@ -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
|
||||
|
@@ -42,16 +42,16 @@ public class DomainMapResource {
|
||||
// Build synchronously, so that we don't need to make the summary API endpoints available over
|
||||
// the domain map server. This means that there will be no loading screen, but this is potentially
|
||||
// preferred in this situation anyway (e.g. to avoid confusing search engine robots).
|
||||
return this.get(domainMap.get(request.getServerName()), ResourceIdType.NAME, Service.WEBSITE, inPath, null, "", false, false);
|
||||
return this.get(domainMap.get(request.getServerName()), ResourceIdType.NAME, Service.WEBSITE, null, inPath, null, "", false, false);
|
||||
}
|
||||
return ArbitraryDataRenderer.getResponse(response, 404, "Error 404: File Not Found");
|
||||
}
|
||||
|
||||
private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String inPath,
|
||||
String secret58, String prefix, boolean usePrefix, boolean async) {
|
||||
private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier,
|
||||
String inPath, String secret58, String prefix, boolean usePrefix, boolean async) {
|
||||
|
||||
ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath,
|
||||
secret58, prefix, usePrefix, async, request, response, context);
|
||||
ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, identifier, inPath,
|
||||
secret58, prefix, usePrefix, async, "domainMap", request, response, context);
|
||||
return renderer.render();
|
||||
}
|
||||
|
||||
|
@@ -2,6 +2,7 @@ package org.qortal.api.gateway.resource;
|
||||
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.qortal.api.Security;
|
||||
import org.qortal.arbitrary.ArbitraryDataFile;
|
||||
import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType;
|
||||
@@ -16,6 +17,10 @@ import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.ws.rs.*;
|
||||
import javax.ws.rs.core.Context;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
@Path("/")
|
||||
@@ -76,50 +81,83 @@ public class GatewayResource {
|
||||
|
||||
|
||||
@GET
|
||||
@Path("{name}/{path:.*}")
|
||||
@Path("{path:.*}")
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public HttpServletResponse getPathByName(@PathParam("name") String name,
|
||||
@PathParam("path") String inPath) {
|
||||
public HttpServletResponse getPath(@PathParam("path") String inPath) {
|
||||
// Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data
|
||||
Security.disallowLoopbackRequests(request);
|
||||
return this.get(name, ResourceIdType.NAME, Service.WEBSITE, inPath, null, "", true, true);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{name}")
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public HttpServletResponse getIndexByName(@PathParam("name") String name) {
|
||||
// Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data
|
||||
Security.disallowLoopbackRequests(request);
|
||||
return this.get(name, ResourceIdType.NAME, Service.WEBSITE, "/", null, "", true, true);
|
||||
}
|
||||
|
||||
|
||||
// Optional /site alternative for backwards support
|
||||
|
||||
@GET
|
||||
@Path("/site/{name}/{path:.*}")
|
||||
public HttpServletResponse getSitePathByName(@PathParam("name") String name,
|
||||
@PathParam("path") String inPath) {
|
||||
// Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data
|
||||
Security.disallowLoopbackRequests(request);
|
||||
return this.get(name, ResourceIdType.NAME, Service.WEBSITE, inPath, null, "/site", true, true);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/site/{name}")
|
||||
public HttpServletResponse getSiteIndexByName(@PathParam("name") String name) {
|
||||
// Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data
|
||||
Security.disallowLoopbackRequests(request);
|
||||
return this.get(name, ResourceIdType.NAME, Service.WEBSITE, "/", null, "/site", true, true);
|
||||
return this.parsePath(inPath, "gateway", null, true, true);
|
||||
}
|
||||
|
||||
|
||||
private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String inPath,
|
||||
String secret58, String prefix, boolean usePrefix, boolean async) {
|
||||
private HttpServletResponse parsePath(String inPath, String qdnContext, String secret58, boolean usePrefix, boolean async) {
|
||||
|
||||
ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath,
|
||||
secret58, prefix, usePrefix, async, request, response, context);
|
||||
if (inPath == null || inPath.equals("")) {
|
||||
// Assume not a real file
|
||||
return ArbitraryDataRenderer.getResponse(response, 404, "Error 404: File Not Found");
|
||||
}
|
||||
|
||||
// Default service is WEBSITE
|
||||
Service service = Service.WEBSITE;
|
||||
String name = null;
|
||||
String identifier = null;
|
||||
String outPath = "";
|
||||
List<String> prefixParts = new ArrayList<>();
|
||||
|
||||
if (!inPath.contains("/")) {
|
||||
// Assume entire inPath is a registered name
|
||||
name = inPath;
|
||||
}
|
||||
else {
|
||||
// Parse the path to determine what we need to load
|
||||
List<String> parts = new LinkedList<>(Arrays.asList(inPath.split("/")));
|
||||
|
||||
// Check if the first element is a service
|
||||
try {
|
||||
Service parsedService = Service.valueOf(parts.get(0).toUpperCase());
|
||||
if (parsedService != null) {
|
||||
// First element matches a service, so we can assume it is one
|
||||
service = parsedService;
|
||||
parts.remove(0);
|
||||
prefixParts.add(service.name());
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Not a service
|
||||
}
|
||||
|
||||
if (parts.isEmpty()) {
|
||||
// We need more than just a service
|
||||
return ArbitraryDataRenderer.getResponse(response, 404, "Error 404: File Not Found");
|
||||
}
|
||||
|
||||
// Service is removed, so assume first element is now a registered name
|
||||
name = parts.get(0);
|
||||
parts.remove(0);
|
||||
|
||||
if (!parts.isEmpty()) {
|
||||
// Name is removed, so check if the first element is now an identifier
|
||||
ArbitraryResourceStatus status = this.getStatus(service, name, parts.get(0), false);
|
||||
if (status.getTotalChunkCount() > 0) {
|
||||
// Matched service, name and identifier combination - so assume this is an identifier and can be removed
|
||||
identifier = parts.get(0);
|
||||
parts.remove(0);
|
||||
prefixParts.add(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
if (!parts.isEmpty()) {
|
||||
// outPath can be built by combining any remaining parts
|
||||
outPath = String.join("/", parts);
|
||||
}
|
||||
}
|
||||
|
||||
String prefix = StringUtils.join(prefixParts, "/");
|
||||
if (prefix != null && prefix.length() > 0) {
|
||||
prefix = "/" + prefix;
|
||||
}
|
||||
|
||||
ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(name, ResourceIdType.NAME, service, identifier, outPath,
|
||||
secret58, prefix, usePrefix, async, qdnContext, request, response, context);
|
||||
return renderer.render();
|
||||
}
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.data.block.BlockSummaryData;
|
||||
import org.qortal.data.network.PeerData;
|
||||
import org.qortal.network.Handshake;
|
||||
@@ -36,6 +37,7 @@ public class ConnectedPeer {
|
||||
public Long lastBlockTimestamp;
|
||||
public UUID connectionId;
|
||||
public String age;
|
||||
public Boolean isTooDivergent;
|
||||
|
||||
protected ConnectedPeer() {
|
||||
}
|
||||
@@ -69,6 +71,11 @@ public class ConnectedPeer {
|
||||
this.lastBlockSignature = peerChainTipData.getSignature();
|
||||
this.lastBlockTimestamp = peerChainTipData.getTimestamp();
|
||||
}
|
||||
|
||||
// Only include isTooDivergent decision if we've had the opportunity to request block summaries this peer
|
||||
if (peer.getLastTooDivergentTime() != null) {
|
||||
this.isTooDivergent = Controller.wasRecentlyTooDivergent.test(peer);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
16
src/main/java/org/qortal/api/model/FileProperties.java
Normal file
16
src/main/java/org/qortal/api/model/FileProperties.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package org.qortal.api.model;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class FileProperties {
|
||||
|
||||
public String filename;
|
||||
public String mimeType;
|
||||
public Long size;
|
||||
|
||||
public FileProperties() {
|
||||
}
|
||||
|
||||
}
|
83
src/main/java/org/qortal/api/resource/AppsResource.java
Normal file
83
src/main/java/org/qortal/api/resource/AppsResource.java
Normal file
@@ -0,0 +1,83 @@
|
||||
package org.qortal.api.resource;
|
||||
|
||||
import com.google.common.io.Resources;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
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.*;
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@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-gateway.js")
|
||||
@Hidden // For internal Q-App API use only
|
||||
@Operation(
|
||||
summary = "Gateway-specific interface for Q-Apps",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "javascript",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
public String getQAppsGatewayJs() {
|
||||
URL url = Resources.getResource("q-apps/q-apps-gateway.js");
|
||||
try {
|
||||
return Resources.toString(url, StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FILE_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -1,6 +1,8 @@
|
||||
package org.qortal.api.resource;
|
||||
|
||||
import com.google.common.primitives.Bytes;
|
||||
import com.j256.simplemagic.ContentInfo;
|
||||
import com.j256.simplemagic.ContentInfoUtil;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
@@ -12,11 +14,14 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.FileNameMap;
|
||||
import java.net.URLConnection;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import javax.servlet.ServletContext;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
@@ -25,11 +30,13 @@ import javax.ws.rs.*;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.bouncycastle.util.encoders.Base64;
|
||||
import org.qortal.api.*;
|
||||
import org.qortal.api.model.FileProperties;
|
||||
import org.qortal.api.resource.TransactionsResource.ConfirmationStatus;
|
||||
import org.qortal.arbitrary.*;
|
||||
import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType;
|
||||
@@ -38,6 +45,7 @@ import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata;
|
||||
import org.qortal.arbitrary.misc.Category;
|
||||
import org.qortal.arbitrary.misc.Service;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.controller.arbitrary.ArbitraryDataRenderManager;
|
||||
import org.qortal.controller.arbitrary.ArbitraryDataStorageManager;
|
||||
import org.qortal.controller.arbitrary.ArbitraryMetadataManager;
|
||||
import org.qortal.data.account.AccountData;
|
||||
@@ -57,10 +65,7 @@ import org.qortal.transaction.Transaction.ValidationResult;
|
||||
import org.qortal.transform.TransformationException;
|
||||
import org.qortal.transform.transaction.ArbitraryTransactionTransformer;
|
||||
import org.qortal.transform.transaction.TransactionTransformer;
|
||||
import org.qortal.utils.ArbitraryTransactionUtils;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.NTP;
|
||||
import org.qortal.utils.ZipUtils;
|
||||
import org.qortal.utils.*;
|
||||
|
||||
@Path("/arbitrary")
|
||||
@Tag(name = "Arbitrary")
|
||||
@@ -88,12 +93,15 @@ public class ArbitraryResource {
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
public List<ArbitraryResourceInfo> getResources(
|
||||
@QueryParam("service") Service service,
|
||||
@QueryParam("name") String name,
|
||||
@QueryParam("identifier") String identifier,
|
||||
@Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource,
|
||||
@Parameter(ref = "limit") @QueryParam("limit") Integer limit,
|
||||
@Parameter(ref = "offset") @QueryParam("offset") Integer offset,
|
||||
@Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse,
|
||||
@Parameter(description = "Filter names by list") @QueryParam("namefilter") String nameFilter,
|
||||
@Parameter(description = "Include followed names only") @QueryParam("followedonly") Boolean followedOnly,
|
||||
@Parameter(description = "Exclude blocked content") @QueryParam("excludeblocked") Boolean excludeBlocked,
|
||||
@Parameter(description = "Filter names by list") @QueryParam("namefilter") String nameListFilter,
|
||||
@Parameter(description = "Include status") @QueryParam("includestatus") Boolean includeStatus,
|
||||
@Parameter(description = "Include metadata") @QueryParam("includemetadata") Boolean includeMetadata) {
|
||||
|
||||
@@ -110,28 +118,33 @@ public class ArbitraryResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "identifier cannot be specified when requesting a default resource");
|
||||
}
|
||||
|
||||
// Load filter from list if needed
|
||||
// Set up name filters if supplied
|
||||
List<String> names = null;
|
||||
if (nameFilter != null) {
|
||||
names = ResourceListManager.getInstance().getStringsInList(nameFilter);
|
||||
if (name != null) {
|
||||
// Filter using single name
|
||||
names = Arrays.asList(name);
|
||||
}
|
||||
else if (nameListFilter != null) {
|
||||
// Filter using supplied list of names
|
||||
names = ResourceListManager.getInstance().getStringsInList(nameListFilter);
|
||||
if (names.isEmpty()) {
|
||||
// List doesn't exist or is empty - so there will be no matches
|
||||
// If list is empty (or doesn't exist) we can shortcut with empty response
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
List<ArbitraryResourceInfo> resources = repository.getArbitraryRepository()
|
||||
.getArbitraryResources(service, identifier, names, defaultRes, limit, offset, reverse);
|
||||
.getArbitraryResources(service, identifier, names, defaultRes, followedOnly, excludeBlocked, limit, offset, reverse);
|
||||
|
||||
if (resources == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -155,30 +168,56 @@ public class ArbitraryResource {
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
public List<ArbitraryResourceInfo> searchResources(
|
||||
@QueryParam("service") Service service,
|
||||
@QueryParam("query") String query,
|
||||
@Parameter(description = "Query (searches both name and identifier fields)") @QueryParam("query") String query,
|
||||
@Parameter(description = "Identifier (searches identifier field only)") @QueryParam("identifier") String identifier,
|
||||
@Parameter(description = "Name (searches name field only)") @QueryParam("name") List<String> names,
|
||||
@Parameter(description = "Prefix only (if true, only the beginning of fields are matched)") @QueryParam("prefix") Boolean prefixOnly,
|
||||
@Parameter(description = "Exact match names only (if true, partial name matches are excluded)") @QueryParam("exactmatchnames") Boolean exactMatchNamesOnly,
|
||||
@Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource,
|
||||
@Parameter(description = "Filter names by list (exact matches only)") @QueryParam("namefilter") String nameListFilter,
|
||||
@Parameter(description = "Include followed names only") @QueryParam("followedonly") Boolean followedOnly,
|
||||
@Parameter(description = "Exclude blocked content") @QueryParam("excludeblocked") Boolean excludeBlocked,
|
||||
@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,
|
||||
@Parameter(description = "Include status") @QueryParam("includestatus") Boolean includeStatus,
|
||||
@Parameter(description = "Include metadata") @QueryParam("includemetadata") Boolean includeMetadata) {
|
||||
@Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
|
||||
boolean defaultRes = Boolean.TRUE.equals(defaultResource);
|
||||
boolean usePrefixOnly = Boolean.TRUE.equals(prefixOnly);
|
||||
|
||||
List<String> exactMatchNames = new ArrayList<>();
|
||||
|
||||
if (nameListFilter != null) {
|
||||
// Load names from supplied list of names
|
||||
exactMatchNames.addAll(ResourceListManager.getInstance().getStringsInList(nameListFilter));
|
||||
|
||||
// If list is empty (or doesn't exist) we can shortcut with empty response
|
||||
if (exactMatchNames.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
// Move names to exact match list, if requested
|
||||
if (exactMatchNamesOnly != null && exactMatchNamesOnly && names != null) {
|
||||
exactMatchNames.addAll(names);
|
||||
names = null;
|
||||
}
|
||||
|
||||
List<ArbitraryResourceInfo> resources = repository.getArbitraryRepository()
|
||||
.searchArbitraryResources(service, query, defaultRes, limit, offset, reverse);
|
||||
.searchArbitraryResources(service, query, identifier, names, usePrefixOnly, exactMatchNames, defaultRes, followedOnly, excludeBlocked, limit, offset, reverse);
|
||||
|
||||
if (resources == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -188,67 +227,6 @@ public class ArbitraryResource {
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/resources/names")
|
||||
@Operation(
|
||||
summary = "List arbitrary resources available on chain, grouped by creator's name",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ArbitraryResourceInfo.class))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
public List<ArbitraryResourceNameInfo> getResourcesGroupedByName(
|
||||
@QueryParam("service") Service service,
|
||||
@QueryParam("identifier") String identifier,
|
||||
@Parameter(description = "Default resources (without identifiers) only") @QueryParam("default") Boolean defaultResource,
|
||||
@Parameter(ref = "limit") @QueryParam("limit") Integer limit,
|
||||
@Parameter(ref = "offset") @QueryParam("offset") Integer offset,
|
||||
@Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse,
|
||||
@Parameter(description = "Include status") @QueryParam("includestatus") Boolean includeStatus,
|
||||
@Parameter(description = "Include metadata") @QueryParam("includemetadata") Boolean includeMetadata) {
|
||||
|
||||
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 ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "identifier cannot be specified when requesting a default resource");
|
||||
}
|
||||
|
||||
List<ArbitraryResourceNameInfo> creatorNames = repository.getArbitraryRepository()
|
||||
.getArbitraryResourceCreatorNames(service, identifier, defaultRes, limit, offset, reverse);
|
||||
|
||||
for (ArbitraryResourceNameInfo creatorName : creatorNames) {
|
||||
String name = creatorName.name;
|
||||
if (name != null) {
|
||||
List<ArbitraryResourceInfo> resources = repository.getArbitraryRepository()
|
||||
.getArbitraryResources(service, identifier, Arrays.asList(name), defaultRes, null, null, reverse);
|
||||
|
||||
if (includeStatus != null && includeStatus) {
|
||||
resources = this.addStatusToResources(resources);
|
||||
}
|
||||
if (includeMetadata != null && includeMetadata) {
|
||||
resources = this.addMetadataToResources(resources);
|
||||
}
|
||||
|
||||
creatorName.resources = resources;
|
||||
}
|
||||
}
|
||||
|
||||
return creatorNames;
|
||||
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/resource/status/{service}/{name}")
|
||||
@Operation(
|
||||
@@ -266,10 +244,35 @@ public class ArbitraryResource {
|
||||
@PathParam("name") String name,
|
||||
@QueryParam("build") Boolean build) {
|
||||
|
||||
Security.requirePriorAuthorizationOrApiKey(request, name, service, null);
|
||||
if (!Settings.getInstance().isQDNAuthBypassEnabled())
|
||||
Security.requirePriorAuthorizationOrApiKey(request, name, service, null, apiKey);
|
||||
|
||||
return ArbitraryTransactionUtils.getStatus(service, name, null, build);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/resource/properties/{service}/{name}/{identifier}")
|
||||
@Operation(
|
||||
summary = "Get properties of a QDN resource",
|
||||
description = "This attempts a download of the data if it's not available locally. A filename will only be returned for single file resources. mimeType is only returned when it can be determined.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = FileProperties.class))
|
||||
)
|
||||
}
|
||||
)
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public FileProperties getResourceProperties(@HeaderParam(Security.API_KEY_HEADER) String apiKey,
|
||||
@PathParam("service") Service service,
|
||||
@PathParam("name") String name,
|
||||
@PathParam("identifier") String identifier) {
|
||||
|
||||
if (!Settings.getInstance().isQDNAuthBypassEnabled())
|
||||
Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier, apiKey);
|
||||
|
||||
return this.getFileProperties(service, name, identifier);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/resource/status/{service}/{name}/{identifier}")
|
||||
@Operation(
|
||||
@@ -288,7 +291,9 @@ public class ArbitraryResource {
|
||||
@PathParam("identifier") String identifier,
|
||||
@QueryParam("build") Boolean build) {
|
||||
|
||||
Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier);
|
||||
if (!Settings.getInstance().isQDNAuthBypassEnabled())
|
||||
Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier, apiKey);
|
||||
|
||||
return ArbitraryTransactionUtils.getStatus(service, name, identifier, build);
|
||||
}
|
||||
|
||||
@@ -501,6 +506,9 @@ public class ArbitraryResource {
|
||||
}
|
||||
|
||||
for (ArbitraryTransactionData transactionData : transactionDataList) {
|
||||
if (transactionData.getService() == null) {
|
||||
continue;
|
||||
}
|
||||
ArbitraryResourceInfo arbitraryResourceInfo = new ArbitraryResourceInfo();
|
||||
arbitraryResourceInfo.name = transactionData.getName();
|
||||
arbitraryResourceInfo.service = transactionData.getService();
|
||||
@@ -511,10 +519,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;
|
||||
@@ -544,7 +552,7 @@ public class ArbitraryResource {
|
||||
|
||||
Security.checkApiCallAllowed(request);
|
||||
ArbitraryDataResource resource = new ArbitraryDataResource(name, ResourceIdType.NAME, service, identifier);
|
||||
return resource.delete();
|
||||
return resource.delete(false);
|
||||
}
|
||||
|
||||
@POST
|
||||
@@ -641,6 +649,7 @@ public class ArbitraryResource {
|
||||
@PathParam("service") Service service,
|
||||
@PathParam("name") String name,
|
||||
@QueryParam("filepath") String filepath,
|
||||
@QueryParam("encoding") String encoding,
|
||||
@QueryParam("rebuild") boolean rebuild,
|
||||
@QueryParam("async") boolean async,
|
||||
@QueryParam("attempts") Integer attempts) {
|
||||
@@ -650,7 +659,7 @@ public class ArbitraryResource {
|
||||
Security.checkApiCallAllowed(request);
|
||||
}
|
||||
|
||||
return this.download(service, name, null, filepath, rebuild, async, attempts);
|
||||
return this.download(service, name, null, filepath, encoding, rebuild, async, attempts);
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -676,16 +685,17 @@ public class ArbitraryResource {
|
||||
@PathParam("name") String name,
|
||||
@PathParam("identifier") String identifier,
|
||||
@QueryParam("filepath") String filepath,
|
||||
@QueryParam("encoding") String encoding,
|
||||
@QueryParam("rebuild") boolean rebuild,
|
||||
@QueryParam("async") boolean async,
|
||||
@QueryParam("attempts") Integer attempts) {
|
||||
|
||||
// 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);
|
||||
return this.download(service, name, identifier, filepath, encoding, rebuild, async, attempts);
|
||||
}
|
||||
|
||||
|
||||
@@ -708,18 +718,15 @@ public class ArbitraryResource {
|
||||
}
|
||||
)
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public ArbitraryResourceMetadata getMetadata(@HeaderParam(Security.API_KEY_HEADER) String apiKey,
|
||||
@PathParam("service") Service service,
|
||||
@PathParam("name") String name,
|
||||
@PathParam("identifier") String identifier) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
public ArbitraryResourceMetadata getMetadata(@PathParam("service") Service service,
|
||||
@PathParam("name") String name,
|
||||
@PathParam("identifier") String identifier) {
|
||||
ArbitraryDataResource resource = new ArbitraryDataResource(name, ResourceIdType.NAME, service, identifier);
|
||||
|
||||
try {
|
||||
ArbitraryDataTransactionMetadata transactionMetadata = ArbitraryMetadataManager.getInstance().fetchMetadata(resource, false);
|
||||
if (transactionMetadata != null) {
|
||||
ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata);
|
||||
ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata, true);
|
||||
if (resourceMetadata != null) {
|
||||
return resourceMetadata;
|
||||
}
|
||||
@@ -733,7 +740,7 @@ public class ArbitraryResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, e.getMessage());
|
||||
}
|
||||
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FILE_NOT_FOUND);
|
||||
}
|
||||
|
||||
|
||||
@@ -773,6 +780,8 @@ public class ArbitraryResource {
|
||||
@QueryParam("description") String description,
|
||||
@QueryParam("tags") List<String> tags,
|
||||
@QueryParam("category") Category category,
|
||||
@QueryParam("fee") Long fee,
|
||||
@QueryParam("preview") Boolean preview,
|
||||
String path) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -781,7 +790,7 @@ public class ArbitraryResource {
|
||||
}
|
||||
|
||||
return this.upload(Service.valueOf(serviceString), name, null, path, null, null, false,
|
||||
title, description, tags, category);
|
||||
fee, null, title, description, tags, category, preview);
|
||||
}
|
||||
|
||||
@POST
|
||||
@@ -818,6 +827,8 @@ public class ArbitraryResource {
|
||||
@QueryParam("description") String description,
|
||||
@QueryParam("tags") List<String> tags,
|
||||
@QueryParam("category") Category category,
|
||||
@QueryParam("fee") Long fee,
|
||||
@QueryParam("preview") Boolean preview,
|
||||
String path) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -826,7 +837,7 @@ public class ArbitraryResource {
|
||||
}
|
||||
|
||||
return this.upload(Service.valueOf(serviceString), name, identifier, path, null, null, false,
|
||||
title, description, tags, category);
|
||||
fee, null, title, description, tags, category, preview);
|
||||
}
|
||||
|
||||
|
||||
@@ -864,6 +875,9 @@ public class ArbitraryResource {
|
||||
@QueryParam("description") String description,
|
||||
@QueryParam("tags") List<String> tags,
|
||||
@QueryParam("category") Category category,
|
||||
@QueryParam("filename") String filename,
|
||||
@QueryParam("fee") Long fee,
|
||||
@QueryParam("preview") Boolean preview,
|
||||
String base64) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -872,7 +886,7 @@ public class ArbitraryResource {
|
||||
}
|
||||
|
||||
return this.upload(Service.valueOf(serviceString), name, null, null, null, base64, false,
|
||||
title, description, tags, category);
|
||||
fee, filename, title, description, tags, category, preview);
|
||||
}
|
||||
|
||||
@POST
|
||||
@@ -907,6 +921,9 @@ public class ArbitraryResource {
|
||||
@QueryParam("description") String description,
|
||||
@QueryParam("tags") List<String> tags,
|
||||
@QueryParam("category") Category category,
|
||||
@QueryParam("filename") String filename,
|
||||
@QueryParam("fee") Long fee,
|
||||
@QueryParam("preview") Boolean preview,
|
||||
String base64) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -915,7 +932,7 @@ public class ArbitraryResource {
|
||||
}
|
||||
|
||||
return this.upload(Service.valueOf(serviceString), name, identifier, null, null, base64, false,
|
||||
title, description, tags, category);
|
||||
fee, filename, title, description, tags, category, preview);
|
||||
}
|
||||
|
||||
|
||||
@@ -952,6 +969,8 @@ public class ArbitraryResource {
|
||||
@QueryParam("description") String description,
|
||||
@QueryParam("tags") List<String> tags,
|
||||
@QueryParam("category") Category category,
|
||||
@QueryParam("fee") Long fee,
|
||||
@QueryParam("preview") Boolean preview,
|
||||
String base64Zip) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -960,7 +979,7 @@ public class ArbitraryResource {
|
||||
}
|
||||
|
||||
return this.upload(Service.valueOf(serviceString), name, null, null, null, base64Zip, true,
|
||||
title, description, tags, category);
|
||||
fee, null, title, description, tags, category, preview);
|
||||
}
|
||||
|
||||
@POST
|
||||
@@ -995,6 +1014,8 @@ public class ArbitraryResource {
|
||||
@QueryParam("description") String description,
|
||||
@QueryParam("tags") List<String> tags,
|
||||
@QueryParam("category") Category category,
|
||||
@QueryParam("fee") Long fee,
|
||||
@QueryParam("preview") Boolean preview,
|
||||
String base64Zip) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -1003,7 +1024,7 @@ public class ArbitraryResource {
|
||||
}
|
||||
|
||||
return this.upload(Service.valueOf(serviceString), name, identifier, null, null, base64Zip, true,
|
||||
title, description, tags, category);
|
||||
fee, null, title, description, tags, category, preview);
|
||||
}
|
||||
|
||||
|
||||
@@ -1043,6 +1064,9 @@ public class ArbitraryResource {
|
||||
@QueryParam("description") String description,
|
||||
@QueryParam("tags") List<String> tags,
|
||||
@QueryParam("category") Category category,
|
||||
@QueryParam("filename") String filename,
|
||||
@QueryParam("fee") Long fee,
|
||||
@QueryParam("preview") Boolean preview,
|
||||
String string) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -1051,7 +1075,7 @@ public class ArbitraryResource {
|
||||
}
|
||||
|
||||
return this.upload(Service.valueOf(serviceString), name, null, null, string, null, false,
|
||||
title, description, tags, category);
|
||||
fee, filename, title, description, tags, category, preview);
|
||||
}
|
||||
|
||||
@POST
|
||||
@@ -1088,6 +1112,9 @@ public class ArbitraryResource {
|
||||
@QueryParam("description") String description,
|
||||
@QueryParam("tags") List<String> tags,
|
||||
@QueryParam("category") Category category,
|
||||
@QueryParam("filename") String filename,
|
||||
@QueryParam("fee") Long fee,
|
||||
@QueryParam("preview") Boolean preview,
|
||||
String string) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
@@ -1096,15 +1123,48 @@ public class ArbitraryResource {
|
||||
}
|
||||
|
||||
return this.upload(Service.valueOf(serviceString), name, identifier, null, string, null, false,
|
||||
title, description, tags, category);
|
||||
fee, filename, title, description, tags, category, preview);
|
||||
}
|
||||
|
||||
|
||||
// Shared methods
|
||||
|
||||
private String preview(String directoryPath, Service service) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
ArbitraryTransactionData.Method method = ArbitraryTransactionData.Method.PUT;
|
||||
ArbitraryTransactionData.Compression compression = ArbitraryTransactionData.Compression.ZIP;
|
||||
|
||||
ArbitraryDataWriter arbitraryDataWriter = new ArbitraryDataWriter(Paths.get(directoryPath),
|
||||
null, service, null, method, compression,
|
||||
null, null, null, null);
|
||||
try {
|
||||
arbitraryDataWriter.save();
|
||||
} catch (IOException | DataException | InterruptedException | MissingDataException e) {
|
||||
LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage());
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage());
|
||||
} catch (RuntimeException e) {
|
||||
LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage());
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
|
||||
}
|
||||
|
||||
ArbitraryDataFile arbitraryDataFile = arbitraryDataWriter.getArbitraryDataFile();
|
||||
if (arbitraryDataFile != null) {
|
||||
String digest58 = arbitraryDataFile.digest58();
|
||||
if (digest58 != null) {
|
||||
// Pre-authorize resource
|
||||
ArbitraryDataResource resource = new ArbitraryDataResource(digest58, null, null, null);
|
||||
ArbitraryDataRenderManager.getInstance().addToAuthorizedResources(resource);
|
||||
|
||||
return "/render/hash/" + digest58 + "?secret=" + Base58.encode(arbitraryDataFile.getSecret());
|
||||
}
|
||||
}
|
||||
return "Unable to generate preview URL";
|
||||
}
|
||||
|
||||
private String upload(Service service, String name, String identifier,
|
||||
String path, String string, String base64, boolean zipped,
|
||||
String title, String description, List<String> tags, Category category) {
|
||||
String path, String string, String base64, boolean zipped, Long fee, String filename,
|
||||
String title, String description, List<String> tags, Category category,
|
||||
Boolean preview) {
|
||||
// Fetch public key from registered name
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
NameData nameData = repository.getNameRepository().fromName(name);
|
||||
@@ -1113,7 +1173,11 @@ public class ArbitraryResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, error);
|
||||
}
|
||||
|
||||
final Long minLatestBlockTimestamp = NTP.getTime() - (60 * 60 * 1000L);
|
||||
final Long now = NTP.getTime();
|
||||
if (now == null) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NO_TIME_SYNC);
|
||||
}
|
||||
final Long minLatestBlockTimestamp = now - (60 * 60 * 1000L);
|
||||
if (!Controller.getInstance().isUpToDate(minLatestBlockTimestamp)) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCKCHAIN_NEEDS_SYNC);
|
||||
}
|
||||
@@ -1128,7 +1192,12 @@ public class ArbitraryResource {
|
||||
if (path == null) {
|
||||
// See if we have a string instead
|
||||
if (string != null) {
|
||||
File tempFile = File.createTempFile("qortal-", ".tmp");
|
||||
if (filename == null) {
|
||||
// Use current time as filename
|
||||
filename = String.format("qortal-%d", NTP.getTime());
|
||||
}
|
||||
java.nio.file.Path tempDirectory = Files.createTempDirectory("qortal-");
|
||||
File tempFile = Paths.get(tempDirectory.toString(), filename).toFile();
|
||||
tempFile.deleteOnExit();
|
||||
BufferedWriter writer = new BufferedWriter(new FileWriter(tempFile.toPath().toString()));
|
||||
writer.write(string);
|
||||
@@ -1138,7 +1207,12 @@ public class ArbitraryResource {
|
||||
}
|
||||
// ... or base64 encoded raw data
|
||||
else if (base64 != null) {
|
||||
File tempFile = File.createTempFile("qortal-", ".tmp");
|
||||
if (filename == null) {
|
||||
// Use current time as filename
|
||||
filename = String.format("qortal-%d", NTP.getTime());
|
||||
}
|
||||
java.nio.file.Path tempDirectory = Files.createTempDirectory("qortal-");
|
||||
File tempFile = Paths.get(tempDirectory.toString(), filename).toFile();
|
||||
tempFile.deleteOnExit();
|
||||
Files.write(tempFile.toPath(), Base64.decode(base64));
|
||||
path = tempFile.toPath().toString();
|
||||
@@ -1161,15 +1235,25 @@ public class ArbitraryResource {
|
||||
// The actual data will be in a randomly-named subfolder of tempDirectory
|
||||
// Remove hidden folders, i.e. starting with "_", as some systems can add them, e.g. "__MACOSX"
|
||||
String[] files = tempDirectory.toFile().list((parent, child) -> !child.startsWith("_"));
|
||||
if (files.length == 1) { // Single directory or file only
|
||||
if (files != null && files.length == 1) { // Single directory or file only
|
||||
path = Paths.get(tempDirectory.toString(), files[0]).toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finish here if user has requested a preview
|
||||
if (preview != null && preview == true) {
|
||||
return this.preview(path, service);
|
||||
}
|
||||
|
||||
// Default to zero fee if not specified
|
||||
if (fee == null) {
|
||||
fee = 0L;
|
||||
}
|
||||
|
||||
try {
|
||||
ArbitraryDataTransactionBuilder transactionBuilder = new ArbitraryDataTransactionBuilder(
|
||||
repository, publicKey58, Paths.get(path), name, null, service, identifier,
|
||||
repository, publicKey58, fee, Paths.get(path), name, null, service, identifier,
|
||||
title, description, tags, category
|
||||
);
|
||||
|
||||
@@ -1183,12 +1267,13 @@ public class ArbitraryResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
|
||||
}
|
||||
|
||||
} catch (DataException | IOException e) {
|
||||
} catch (Exception e) {
|
||||
LOGGER.info("Exception when publishing data: ", e);
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private HttpServletResponse download(Service service, String name, String identifier, String filepath, boolean rebuild, boolean async, Integer maxAttempts) {
|
||||
private HttpServletResponse download(Service service, String name, String identifier, String filepath, String encoding, boolean rebuild, boolean async, Integer maxAttempts) {
|
||||
|
||||
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
|
||||
try {
|
||||
@@ -1231,7 +1316,7 @@ public class ArbitraryResource {
|
||||
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) {
|
||||
if (files != null && files.length == 1) {
|
||||
// This is a single file resource
|
||||
filepath = files[0];
|
||||
}
|
||||
@@ -1241,13 +1326,50 @@ public class ArbitraryResource {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: limit file size that can be read into memory
|
||||
java.nio.file.Path path = Paths.get(outputPath.toString(), filepath);
|
||||
if (!Files.exists(path)) {
|
||||
String message = String.format("No file exists at filepath: %s", filepath);
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, message);
|
||||
}
|
||||
byte[] data = Files.readAllBytes(path);
|
||||
|
||||
byte[] data;
|
||||
int fileSize = (int)path.toFile().length();
|
||||
int length = fileSize;
|
||||
|
||||
// Parse "Range" header
|
||||
Integer rangeStart = null;
|
||||
Integer rangeEnd = null;
|
||||
String range = request.getHeader("Range");
|
||||
if (range != null) {
|
||||
range = range.replace("bytes=", "");
|
||||
String[] parts = range.split("-");
|
||||
rangeStart = (parts != null && parts.length > 0) ? Integer.parseInt(parts[0]) : null;
|
||||
rangeEnd = (parts != null && parts.length > 1) ? Integer.parseInt(parts[1]) : fileSize;
|
||||
}
|
||||
|
||||
if (rangeStart != null && rangeEnd != null) {
|
||||
// We have a range, so update the requested length
|
||||
length = rangeEnd - rangeStart;
|
||||
}
|
||||
|
||||
if (length < fileSize && encoding == null) {
|
||||
// Partial content requested, and not encoding the data
|
||||
response.setStatus(206);
|
||||
response.addHeader("Content-Range", String.format("bytes %d-%d/%d", rangeStart, rangeEnd-1, fileSize));
|
||||
data = FilesystemUtils.readFromFile(path.toString(), rangeStart, length);
|
||||
}
|
||||
else {
|
||||
// Full content requested (or encoded data)
|
||||
response.setStatus(200);
|
||||
data = Files.readAllBytes(path); // TODO: limit file size that can be read into memory
|
||||
}
|
||||
|
||||
// Encode the data if requested
|
||||
if (encoding != null && Objects.equals(encoding.toLowerCase(), "base64")) {
|
||||
data = Base64.encode(data);
|
||||
}
|
||||
|
||||
response.addHeader("Accept-Ranges", "bytes");
|
||||
response.setContentType(context.getMimeType(path.toString()));
|
||||
response.setContentLength(data.length);
|
||||
response.getOutputStream().write(data);
|
||||
@@ -1259,41 +1381,44 @@ public class ArbitraryResource {
|
||||
}
|
||||
}
|
||||
|
||||
private FileProperties getFileProperties(Service service, String name, String identifier) {
|
||||
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
|
||||
try {
|
||||
arbitraryDataReader.loadSynchronously(false);
|
||||
java.nio.file.Path outputPath = arbitraryDataReader.getFilePath();
|
||||
if (outputPath == null) {
|
||||
// Assume the resource doesn't exist
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, "File not found");
|
||||
}
|
||||
|
||||
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;
|
||||
FileProperties fileProperties = new FileProperties();
|
||||
fileProperties.size = FileUtils.sizeOfDirectory(outputPath.toFile());
|
||||
|
||||
String[] files = ArrayUtils.removeElement(outputPath.toFile().list(), ".qortal");
|
||||
if (files.length == 1) {
|
||||
String filename = files[0];
|
||||
java.nio.file.Path filePath = Paths.get(outputPath.toString(), files[0]);
|
||||
ContentInfoUtil util = new ContentInfoUtil();
|
||||
ContentInfo info = util.findMatch(filePath.toFile());
|
||||
String mimeType;
|
||||
if (info != null) {
|
||||
// Attempt to extract MIME type from file contents
|
||||
mimeType = info.getMimeType();
|
||||
}
|
||||
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());
|
||||
else {
|
||||
// Fall back to using the filename
|
||||
FileNameMap fileNameMap = URLConnection.getFileNameMap();
|
||||
mimeType = fileNameMap.getContentTypeFor(filename);
|
||||
}
|
||||
fileProperties.filename = filename;
|
||||
fileProperties.mimeType = mimeType;
|
||||
}
|
||||
}
|
||||
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 fileProperties;
|
||||
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug(String.format("Unable to load %s %s: %s", service, name, e.getMessage()));
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, e.getMessage());
|
||||
}
|
||||
return updatedResources;
|
||||
}
|
||||
}
|
||||
|
@@ -48,6 +48,7 @@ import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.transform.TransformationException;
|
||||
import org.qortal.transform.block.BlockTransformer;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.Triple;
|
||||
|
||||
@Path("/blocks")
|
||||
@Tag(name = "Blocks")
|
||||
@@ -165,10 +166,13 @@ public class BlocksResource {
|
||||
}
|
||||
|
||||
// Not found, so try the block archive
|
||||
byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, false, repository);
|
||||
if (bytes != null) {
|
||||
if (version != 1) {
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Archived blocks require version 1");
|
||||
Triple<byte[], Integer, Integer> serializedBlock = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, false, repository);
|
||||
if (serializedBlock != null) {
|
||||
byte[] bytes = serializedBlock.getA();
|
||||
Integer serializationVersion = serializedBlock.getB();
|
||||
if (version != serializationVersion) {
|
||||
// TODO: we could quite easily reserialize the block with the requested version
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Block is not stored using requested serialization version.");
|
||||
}
|
||||
return Base58.encode(bytes);
|
||||
}
|
||||
@@ -634,13 +638,16 @@ public class BlocksResource {
|
||||
@ApiErrors({
|
||||
ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
public List<BlockData> getBlockRange(@PathParam("height") int height, @Parameter(
|
||||
ref = "count"
|
||||
) @QueryParam("count") int count) {
|
||||
public List<BlockData> getBlockRange(@PathParam("height") int height,
|
||||
@Parameter(ref = "count") @QueryParam("count") int count,
|
||||
@Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse,
|
||||
@QueryParam("includeOnlineSignatures") Boolean includeOnlineSignatures) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<BlockData> blocks = new ArrayList<>();
|
||||
boolean shouldReverse = (reverse != null && reverse == true);
|
||||
|
||||
for (/* count already set */; count > 0; --count, ++height) {
|
||||
int i = 0;
|
||||
while (i < count) {
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(height);
|
||||
if (blockData == null) {
|
||||
// Not found - try the archive
|
||||
@@ -650,8 +657,14 @@ public class BlocksResource {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (includeOnlineSignatures == null || includeOnlineSignatures == false) {
|
||||
blockData.setOnlineAccountsSignatures(null);
|
||||
}
|
||||
|
||||
blocks.add(blockData);
|
||||
|
||||
height = shouldReverse ? height - 1 : height + 1;
|
||||
i++;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
|
@@ -40,6 +40,8 @@ import org.qortal.utils.Base58;
|
||||
|
||||
import com.google.common.primitives.Bytes;
|
||||
|
||||
import static org.qortal.data.chat.ChatMessage.Encoding;
|
||||
|
||||
@Path("/chat")
|
||||
@Tag(name = "Chat")
|
||||
public class ChatResource {
|
||||
@@ -70,6 +72,10 @@ public class ChatResource {
|
||||
@QueryParam("txGroupId") Integer txGroupId,
|
||||
@QueryParam("involving") List<String> involvingAddresses,
|
||||
@QueryParam("reference") String reference,
|
||||
@QueryParam("chatreference") String chatReference,
|
||||
@QueryParam("haschatreference") Boolean hasChatReference,
|
||||
@QueryParam("sender") String sender,
|
||||
@QueryParam("encoding") Encoding encoding,
|
||||
@Parameter(ref = "limit") @QueryParam("limit") Integer limit,
|
||||
@Parameter(ref = "offset") @QueryParam("offset") Integer offset,
|
||||
@Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
|
||||
@@ -92,19 +98,96 @@ public class ChatResource {
|
||||
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,
|
||||
sender,
|
||||
encoding,
|
||||
limit, offset, reverse);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/messages/count")
|
||||
@Operation(
|
||||
summary = "Count chat messages",
|
||||
description = "Returns count of CHAT messages that match criteria. Must provide EITHER 'txGroupId' OR two 'involving' addresses.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "count of messages",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "integer"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
|
||||
public int countChatMessages(@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("sender") String sender,
|
||||
@QueryParam("encoding") Encoding encoding,
|
||||
@Parameter(ref = "limit") @QueryParam("limit") Integer limit,
|
||||
@Parameter(ref = "offset") @QueryParam("offset") Integer offset,
|
||||
@Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) {
|
||||
// Check args meet expectations
|
||||
if ((txGroupId == null && involvingAddresses.size() != 2)
|
||||
|| (txGroupId != null && !involvingAddresses.isEmpty()))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Check any provided addresses are valid
|
||||
if (involvingAddresses.stream().anyMatch(address -> !Crypto.isValidAddress(address)))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
if (before != null && before < 1500000000000L)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
if (after != null && after < 1500000000000L)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
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,
|
||||
sender,
|
||||
encoding,
|
||||
limit, offset, reverse).size();
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/message/{signature}")
|
||||
@Operation(
|
||||
@@ -121,7 +204,7 @@ public class ChatResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
|
||||
public ChatMessage getMessageBySignature(@PathParam("signature") String signature58) {
|
||||
public ChatMessage getMessageBySignature(@PathParam("signature") String signature58, @QueryParam("encoding") Encoding encoding) {
|
||||
byte[] signature = Base58.decode(signature58);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
@@ -131,7 +214,7 @@ public class ChatResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Message not found");
|
||||
}
|
||||
|
||||
return repository.getChatRepository().toChatMessage(chatTransactionData);
|
||||
return repository.getChatRepository().toChatMessage(chatTransactionData, encoding);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
@@ -154,12 +237,12 @@ public class ChatResource {
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
|
||||
public ActiveChats getActiveChats(@PathParam("address") String address) {
|
||||
public ActiveChats getActiveChats(@PathParam("address") String address, @QueryParam("encoding") Encoding encoding) {
|
||||
if (address == null || !Crypto.isValidAddress(address))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
return repository.getChatRepository().getActiveChats(address);
|
||||
return repository.getChatRepository().getActiveChats(address, encoding);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
|
@@ -14,6 +14,7 @@ import java.util.List;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.HeaderParam;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
@@ -35,6 +36,37 @@ public class CrossChainBitcoinResource {
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@GET
|
||||
@Path("/height")
|
||||
@Operation(
|
||||
summary = "Returns current Bitcoin block height",
|
||||
description = "Returns the height of the most recent block in the Bitcoin chain.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
type = "number"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
public String getBitcoinHeight() {
|
||||
Bitcoin bitcoin = Bitcoin.getInstance();
|
||||
|
||||
try {
|
||||
Integer height = bitcoin.getBlockchainHeight();
|
||||
if (height == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
|
||||
return height.toString();
|
||||
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/walletbalance")
|
||||
@Operation(
|
||||
@@ -68,7 +100,7 @@ public class CrossChainBitcoinResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
try {
|
||||
Long balance = bitcoin.getWalletBalanceFromTransactions(key58);
|
||||
Long balance = bitcoin.getWalletBalance(key58);
|
||||
if (balance == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
|
||||
@@ -118,6 +150,45 @@ public class CrossChainBitcoinResource {
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/unusedaddress")
|
||||
@Operation(
|
||||
summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet",
|
||||
description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string",
|
||||
description = "BIP32 'm' private/public key in base58",
|
||||
example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String getUnusedBitcoinReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
Bitcoin bitcoin = Bitcoin.getInstance();
|
||||
|
||||
if (!bitcoin.isValidDeterministicKey(key58))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
try {
|
||||
return bitcoin.getUnusedReceiveAddress(key58);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/send")
|
||||
@Operation(
|
||||
|
@@ -14,6 +14,7 @@ import java.util.List;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.HeaderParam;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
@@ -35,6 +36,37 @@ public class CrossChainDigibyteResource {
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@GET
|
||||
@Path("/height")
|
||||
@Operation(
|
||||
summary = "Returns current Digibyte block height",
|
||||
description = "Returns the height of the most recent block in the Digibyte chain.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
type = "number"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
public String getDigibyteHeight() {
|
||||
Digibyte digibyte = Digibyte.getInstance();
|
||||
|
||||
try {
|
||||
Integer height = digibyte.getBlockchainHeight();
|
||||
if (height == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
|
||||
return height.toString();
|
||||
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/walletbalance")
|
||||
@Operation(
|
||||
@@ -68,7 +100,7 @@ public class CrossChainDigibyteResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
try {
|
||||
Long balance = digibyte.getWalletBalanceFromTransactions(key58);
|
||||
Long balance = digibyte.getWalletBalance(key58);
|
||||
if (balance == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
|
||||
@@ -118,6 +150,45 @@ public class CrossChainDigibyteResource {
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/unusedaddress")
|
||||
@Operation(
|
||||
summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet",
|
||||
description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string",
|
||||
description = "BIP32 'm' private/public key in base58",
|
||||
example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String getUnusedDigibyteReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
Digibyte digibyte = Digibyte.getInstance();
|
||||
|
||||
if (!digibyte.isValidDeterministicKey(key58))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
try {
|
||||
return digibyte.getUnusedReceiveAddress(key58);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/send")
|
||||
@Operation(
|
||||
|
@@ -21,6 +21,7 @@ import org.qortal.crosschain.SimpleTransaction;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.HeaderParam;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
@@ -33,6 +34,37 @@ public class CrossChainDogecoinResource {
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@GET
|
||||
@Path("/height")
|
||||
@Operation(
|
||||
summary = "Returns current Dogecoin block height",
|
||||
description = "Returns the height of the most recent block in the Dogecoin chain.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
type = "number"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
public String getDogecoinHeight() {
|
||||
Dogecoin dogecoin = Dogecoin.getInstance();
|
||||
|
||||
try {
|
||||
Integer height = dogecoin.getBlockchainHeight();
|
||||
if (height == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
|
||||
return height.toString();
|
||||
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/walletbalance")
|
||||
@Operation(
|
||||
@@ -66,7 +98,7 @@ public class CrossChainDogecoinResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
try {
|
||||
Long balance = dogecoin.getWalletBalanceFromTransactions(key58);
|
||||
Long balance = dogecoin.getWalletBalance(key58);
|
||||
if (balance == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
|
||||
@@ -116,6 +148,45 @@ public class CrossChainDogecoinResource {
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/unusedaddress")
|
||||
@Operation(
|
||||
summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet",
|
||||
description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string",
|
||||
description = "BIP32 'm' private/public key in base58",
|
||||
example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String getUnusedDogecoinReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
Dogecoin dogecoin = Dogecoin.getInstance();
|
||||
|
||||
if (!dogecoin.isValidDeterministicKey(key58))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
try {
|
||||
return dogecoin.getUnusedReceiveAddress(key58);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/send")
|
||||
@Operation(
|
||||
|
@@ -8,11 +8,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.*;
|
||||
@@ -25,7 +24,6 @@ import org.bitcoinj.core.*;
|
||||
import org.bitcoinj.script.Script;
|
||||
import org.qortal.api.*;
|
||||
import org.qortal.api.model.CrossChainBitcoinyHTLCStatus;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.crosschain.*;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.at.ATData;
|
||||
@@ -586,98 +584,103 @@ public class CrossChainHtlcResource {
|
||||
}
|
||||
|
||||
List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
|
||||
TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null);
|
||||
if (tradeBotData == null)
|
||||
List<TradeBotData> tradeBotDataList = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).collect(Collectors.toList());
|
||||
if (tradeBotDataList == null || tradeBotDataList.isEmpty())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain();
|
||||
int lockTime = tradeBotData.getLockTimeA();
|
||||
// Loop through all matching entries for this AT address, as there might be more than one
|
||||
for (TradeBotData tradeBotData : tradeBotDataList) {
|
||||
|
||||
// We can't refund P2SH-A until lockTime-A has passed
|
||||
if (NTP.getTime() <= lockTime * 1000L)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON);
|
||||
if (tradeBotData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113)
|
||||
int medianBlockTime = bitcoiny.getMedianBlockTime();
|
||||
if (medianBlockTime <= lockTime)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON);
|
||||
Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain();
|
||||
int lockTime = tradeBotData.getLockTimeA();
|
||||
|
||||
// Fee for redeem/refund is subtracted from P2SH-A balance.
|
||||
long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout);
|
||||
long p2shFee = bitcoiny.getP2shFee(feeTimestamp);
|
||||
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||
// We can't refund P2SH-A until lockTime-A has passed
|
||||
if (NTP.getTime() <= lockTime * 1000L)
|
||||
continue;
|
||||
|
||||
// Create redeem script based on destination chain
|
||||
byte[] redeemScriptA;
|
||||
String p2shAddressA;
|
||||
BitcoinyHTLC.Status htlcStatusA;
|
||||
if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) {
|
||||
redeemScriptA = PirateChainHTLC.buildScript(tradeBotData.getTradeForeignPublicKey(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
|
||||
p2shAddressA = PirateChain.getInstance().deriveP2shAddressBPrefix(redeemScriptA);
|
||||
htlcStatusA = PirateChainHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||
}
|
||||
else {
|
||||
redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
|
||||
p2shAddressA = bitcoiny.deriveP2shAddress(redeemScriptA);
|
||||
htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||
}
|
||||
LOGGER.info(String.format("Refunding P2SH address: %s", p2shAddressA));
|
||||
// We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113)
|
||||
int medianBlockTime = bitcoiny.getMedianBlockTime();
|
||||
if (medianBlockTime <= lockTime)
|
||||
continue;
|
||||
|
||||
switch (htlcStatusA) {
|
||||
case UNFUNDED:
|
||||
case FUNDING_IN_PROGRESS:
|
||||
// Still waiting for P2SH-A to be funded...
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON);
|
||||
// Fee for redeem/refund is subtracted from P2SH-A balance.
|
||||
long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout);
|
||||
long p2shFee = bitcoiny.getP2shFee(feeTimestamp);
|
||||
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||
|
||||
case REDEEM_IN_PROGRESS:
|
||||
case REDEEMED:
|
||||
case REFUND_IN_PROGRESS:
|
||||
case REFUNDED:
|
||||
// Too late!
|
||||
return false;
|
||||
// Create redeem script based on destination chain
|
||||
byte[] redeemScriptA;
|
||||
String p2shAddressA;
|
||||
BitcoinyHTLC.Status htlcStatusA;
|
||||
if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) {
|
||||
redeemScriptA = PirateChainHTLC.buildScript(tradeBotData.getTradeForeignPublicKey(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
|
||||
p2shAddressA = PirateChain.getInstance().deriveP2shAddressBPrefix(redeemScriptA);
|
||||
htlcStatusA = PirateChainHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||
} else {
|
||||
redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
|
||||
p2shAddressA = bitcoiny.deriveP2shAddress(redeemScriptA);
|
||||
htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||
}
|
||||
LOGGER.info(String.format("Refunding P2SH address: %s", p2shAddressA));
|
||||
|
||||
case FUNDED:{
|
||||
Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount);
|
||||
switch (htlcStatusA) {
|
||||
case UNFUNDED:
|
||||
case FUNDING_IN_PROGRESS:
|
||||
// Still waiting for P2SH-A to be funded...
|
||||
continue;
|
||||
|
||||
if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) {
|
||||
// Pirate Chain custom integration
|
||||
case REDEEM_IN_PROGRESS:
|
||||
case REDEEMED:
|
||||
case REFUND_IN_PROGRESS:
|
||||
case REFUNDED:
|
||||
// Too late!
|
||||
continue;
|
||||
|
||||
PirateChain pirateChain = PirateChain.getInstance();
|
||||
String p2shAddressT3 = pirateChain.deriveP2shAddress(redeemScriptA);
|
||||
case FUNDED: {
|
||||
Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount);
|
||||
|
||||
// Get funding txid
|
||||
String fundingTxidHex = PirateChainHTLC.getUnspentFundingTxid(pirateChain.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||
if (fundingTxidHex == null) {
|
||||
throw new ForeignBlockchainException("Missing funding txid when refunding P2SH");
|
||||
if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) {
|
||||
// Pirate Chain custom integration
|
||||
|
||||
PirateChain pirateChain = PirateChain.getInstance();
|
||||
String p2shAddressT3 = pirateChain.deriveP2shAddress(redeemScriptA);
|
||||
|
||||
// Get funding txid
|
||||
String fundingTxidHex = PirateChainHTLC.getUnspentFundingTxid(pirateChain.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||
if (fundingTxidHex == null) {
|
||||
throw new ForeignBlockchainException("Missing funding txid when refunding P2SH");
|
||||
}
|
||||
String fundingTxid58 = Base58.encode(HashCode.fromString(fundingTxidHex).asBytes());
|
||||
|
||||
byte[] privateKey = tradeBotData.getTradePrivateKey();
|
||||
String privateKey58 = Base58.encode(privateKey);
|
||||
String redeemScript58 = Base58.encode(redeemScriptA);
|
||||
|
||||
String txid = PirateChain.getInstance().refundP2sh(p2shAddressT3,
|
||||
receiveAddress, refundAmount.value, redeemScript58, fundingTxid58, lockTime, privateKey58);
|
||||
LOGGER.info("Refund txid: {}", txid);
|
||||
} else {
|
||||
// ElectrumX coins
|
||||
|
||||
ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
|
||||
List<TransactionOutput> fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA);
|
||||
|
||||
// Validate the destination foreign blockchain address
|
||||
Address receiving = Address.fromString(bitcoiny.getNetworkParameters(), receiveAddress);
|
||||
if (receiving.getOutputScriptType() != Script.ScriptType.P2PKH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoiny.getNetworkParameters(), refundAmount, refundKey,
|
||||
fundingOutputs, redeemScriptA, lockTime, receiving.getHash());
|
||||
|
||||
bitcoiny.broadcastTransaction(p2shRefundTransaction);
|
||||
}
|
||||
String fundingTxid58 = Base58.encode(HashCode.fromString(fundingTxidHex).asBytes());
|
||||
|
||||
byte[] privateKey = tradeBotData.getTradePrivateKey();
|
||||
String privateKey58 = Base58.encode(privateKey);
|
||||
String redeemScript58 = Base58.encode(redeemScriptA);
|
||||
|
||||
String txid = PirateChain.getInstance().refundP2sh(p2shAddressT3,
|
||||
receiveAddress, refundAmount.value, redeemScript58, fundingTxid58, lockTime, privateKey58);
|
||||
LOGGER.info("Refund txid: {}", txid);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
// ElectrumX coins
|
||||
|
||||
ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
|
||||
List<TransactionOutput> fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA);
|
||||
|
||||
// Validate the destination foreign blockchain address
|
||||
Address receiving = Address.fromString(bitcoiny.getNetworkParameters(), receiveAddress);
|
||||
if (receiving.getOutputScriptType() != Script.ScriptType.P2PKH)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoiny.getNetworkParameters(), refundAmount, refundKey,
|
||||
fundingOutputs, redeemScriptA, lockTime, receiving.getHash());
|
||||
|
||||
bitcoiny.broadcastTransaction(p2shRefundTransaction);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -14,6 +14,7 @@ import java.util.List;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.HeaderParam;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
@@ -35,6 +36,37 @@ public class CrossChainLitecoinResource {
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@GET
|
||||
@Path("/height")
|
||||
@Operation(
|
||||
summary = "Returns current Litecoin block height",
|
||||
description = "Returns the height of the most recent block in the Litecoin chain.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
type = "number"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
public String getLitecoinHeight() {
|
||||
Litecoin litecoin = Litecoin.getInstance();
|
||||
|
||||
try {
|
||||
Integer height = litecoin.getBlockchainHeight();
|
||||
if (height == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
|
||||
return height.toString();
|
||||
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/walletbalance")
|
||||
@Operation(
|
||||
@@ -68,7 +100,7 @@ public class CrossChainLitecoinResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
try {
|
||||
Long balance = litecoin.getWalletBalanceFromTransactions(key58);
|
||||
Long balance = litecoin.getWalletBalance(key58);
|
||||
if (balance == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
|
||||
@@ -118,6 +150,45 @@ public class CrossChainLitecoinResource {
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/unusedaddress")
|
||||
@Operation(
|
||||
summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet",
|
||||
description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string",
|
||||
description = "BIP32 'm' private/public key in base58",
|
||||
example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String getUnusedLitecoinReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
Litecoin litecoin = Litecoin.getInstance();
|
||||
|
||||
if (!litecoin.isValidDeterministicKey(key58))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
try {
|
||||
return litecoin.getUnusedReceiveAddress(key58);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/send")
|
||||
@Operation(
|
||||
|
@@ -20,6 +20,7 @@ import org.qortal.crosschain.SimpleTransaction;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.HeaderParam;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
@@ -32,6 +33,37 @@ public class CrossChainPirateChainResource {
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@GET
|
||||
@Path("/height")
|
||||
@Operation(
|
||||
summary = "Returns current PirateChain block height",
|
||||
description = "Returns the height of the most recent block in the PirateChain chain.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
type = "number"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
public String getPirateChainHeight() {
|
||||
PirateChain pirateChain = PirateChain.getInstance();
|
||||
|
||||
try {
|
||||
Integer height = pirateChain.getBlockchainHeight();
|
||||
if (height == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
|
||||
return height.toString();
|
||||
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/walletbalance")
|
||||
@Operation(
|
||||
|
@@ -14,6 +14,7 @@ import java.util.List;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.HeaderParam;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
@@ -35,6 +36,37 @@ public class CrossChainRavencoinResource {
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@GET
|
||||
@Path("/height")
|
||||
@Operation(
|
||||
summary = "Returns current Ravencoin block height",
|
||||
description = "Returns the height of the most recent block in the Ravencoin chain.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
type = "number"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
public String getRavencoinHeight() {
|
||||
Ravencoin ravencoin = Ravencoin.getInstance();
|
||||
|
||||
try {
|
||||
Integer height = ravencoin.getBlockchainHeight();
|
||||
if (height == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
|
||||
return height.toString();
|
||||
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/walletbalance")
|
||||
@Operation(
|
||||
@@ -68,7 +100,7 @@ public class CrossChainRavencoinResource {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
try {
|
||||
Long balance = ravencoin.getWalletBalanceFromTransactions(key58);
|
||||
Long balance = ravencoin.getWalletBalance(key58);
|
||||
if (balance == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
|
||||
@@ -118,6 +150,45 @@ public class CrossChainRavencoinResource {
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/unusedaddress")
|
||||
@Operation(
|
||||
summary = "Returns first unused address for hierarchical, deterministic BIP32 wallet",
|
||||
description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string",
|
||||
description = "BIP32 'm' private/public key in base58",
|
||||
example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String getUnusedRavencoinReceiveAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
Ravencoin ravencoin = Ravencoin.getInstance();
|
||||
|
||||
if (!ravencoin.isValidDeterministicKey(key58))
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||
|
||||
try {
|
||||
return ravencoin.getUnusedReceiveAddress(key58);
|
||||
} catch (ForeignBlockchainException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/send")
|
||||
@Operation(
|
||||
|
@@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
@@ -38,9 +39,12 @@ import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.at.ATData;
|
||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||
import org.qortal.data.crosschain.TradeBotData;
|
||||
import org.qortal.data.transaction.MessageTransactionData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.transaction.Transaction;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
@@ -223,6 +227,17 @@ public class CrossChainTradeBotResource {
|
||||
if (crossChainTradeData.mode != AcctMode.OFFERING)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||
|
||||
// Check if there is a buy or a cancel request in progress for this trade
|
||||
List<Transaction.TransactionType> txTypes = List.of(Transaction.TransactionType.MESSAGE);
|
||||
List<TransactionData> unconfirmed = repository.getTransactionRepository().getUnconfirmedTransactions(txTypes, null, 0, 0, false);
|
||||
for (TransactionData transactionData : unconfirmed) {
|
||||
MessageTransactionData messageTransactionData = (MessageTransactionData) transactionData;
|
||||
if (Objects.equals(messageTransactionData.getRecipient(), atAddress)) {
|
||||
// There is a pending request for this trade, so block this buy attempt to reduce the risk of refunds
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Trade has an existing buy request or is pending cancellation.");
|
||||
}
|
||||
}
|
||||
|
||||
AcctTradeBot.ResponseResult result = TradeBot.getInstance().startResponse(repository, atData, acct, crossChainTradeData,
|
||||
tradeBotRespondRequest.foreignKey, tradeBotRespondRequest.receivingAddress);
|
||||
|
||||
|
@@ -155,6 +155,38 @@ public class NamesResource {
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/search")
|
||||
@Operation(
|
||||
summary = "Search registered names",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "registered name info",
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
array = @ArraySchema(schema = @Schema(implementation = NameData.class))
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.NAME_UNKNOWN, ApiError.REPOSITORY_ISSUE})
|
||||
public List<NameData> searchNames(@QueryParam("query") String query,
|
||||
@Parameter(ref = "limit") @QueryParam("limit") Integer limit,
|
||||
@Parameter(ref = "offset") @QueryParam("offset") Integer offset,
|
||||
@Parameter(ref="reverse") @QueryParam("reverse") Boolean reverse) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
if (query == null) {
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Missing query");
|
||||
}
|
||||
|
||||
return repository.getNameRepository().searchNames(query, limit, offset, reverse);
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@POST
|
||||
@Path("/register")
|
||||
|
197
src/main/java/org/qortal/api/resource/PollsResource.java
Normal file
197
src/main/java/org/qortal/api/resource/PollsResource.java
Normal file
@@ -0,0 +1,197 @@
|
||||
package org.qortal.api.resource;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.data.transaction.CreatePollTransactionData;
|
||||
import org.qortal.data.transaction.PaymentTransactionData;
|
||||
import org.qortal.data.transaction.VoteOnPollTransactionData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transaction.Transaction;
|
||||
import org.qortal.transform.TransformationException;
|
||||
import org.qortal.transform.transaction.CreatePollTransactionTransformer;
|
||||
import org.qortal.transform.transaction.PaymentTransactionTransformer;
|
||||
import org.qortal.transform.transaction.VoteOnPollTransactionTransformer;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import java.util.List;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import org.qortal.api.ApiException;
|
||||
import org.qortal.data.voting.PollData;
|
||||
|
||||
@Path("/polls")
|
||||
@Tag(name = "Polls")
|
||||
public class PollsResource {
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@GET
|
||||
@Operation(
|
||||
summary = "List all polls",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "poll info",
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
array = @ArraySchema(schema = @Schema(implementation = PollData.class))
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
public List<PollData> getAllPolls(@Parameter(
|
||||
ref = "limit"
|
||||
) @QueryParam("limit") Integer limit, @Parameter(
|
||||
ref = "offset"
|
||||
) @QueryParam("offset") Integer offset, @Parameter(
|
||||
ref = "reverse"
|
||||
) @QueryParam("reverse") Boolean reverse) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<PollData> allPollData = repository.getVotingRepository().getAllPolls(limit, offset, reverse);
|
||||
return allPollData;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{pollName}")
|
||||
@Operation(
|
||||
summary = "Info on poll",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "poll info",
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = PollData.class)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
public PollData getPollData(@PathParam("pollName") String pollName) {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
PollData pollData = repository.getVotingRepository().fromPollName(pollName);
|
||||
if (pollData == null)
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.POLL_NO_EXISTS);
|
||||
|
||||
return pollData;
|
||||
} catch (ApiException e) {
|
||||
throw e;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/create")
|
||||
@Operation(
|
||||
summary = "Build raw, unsigned, CREATE_POLL transaction",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = CreatePollTransactionData.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "raw, unsigned, CREATE_POLL transaction encoded in Base58",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.NON_PRODUCTION, ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
|
||||
public String CreatePoll(CreatePollTransactionData transactionData) {
|
||||
if (Settings.getInstance().isApiRestricted())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
Transaction transaction = Transaction.fromData(repository, transactionData);
|
||||
|
||||
Transaction.ValidationResult result = transaction.isValidUnconfirmed();
|
||||
if (result != Transaction.ValidationResult.OK)
|
||||
throw TransactionsResource.createTransactionInvalidException(request, result);
|
||||
|
||||
byte[] bytes = CreatePollTransactionTransformer.toBytes(transactionData);
|
||||
return Base58.encode(bytes);
|
||||
} catch (TransformationException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/vote")
|
||||
@Operation(
|
||||
summary = "Build raw, unsigned, VOTE_ON_POLL transaction",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(
|
||||
implementation = VoteOnPollTransactionData.class
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "raw, unsigned, VOTE_ON_POLL transaction encoded in Base58",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.NON_PRODUCTION, ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
|
||||
public String VoteOnPoll(VoteOnPollTransactionData transactionData) {
|
||||
if (Settings.getInstance().isApiRestricted())
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
Transaction transaction = Transaction.fromData(repository, transactionData);
|
||||
|
||||
Transaction.ValidationResult result = transaction.isValidUnconfirmed();
|
||||
if (result != Transaction.ValidationResult.OK)
|
||||
throw TransactionsResource.createTransactionInvalidException(request, result);
|
||||
|
||||
byte[] bytes = VoteOnPollTransactionTransformer.toBytes(transactionData);
|
||||
return Base58.encode(bytes);
|
||||
} catch (TransformationException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -9,6 +9,8 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.util.ArrayList;
|
||||
@@ -18,19 +20,12 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import javax.ws.rs.*;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
|
||||
import org.qortal.account.PrivateKeyAccount;
|
||||
import org.qortal.api.ApiError;
|
||||
import org.qortal.api.ApiErrors;
|
||||
import org.qortal.api.ApiException;
|
||||
import org.qortal.api.ApiExceptionFactory;
|
||||
import org.qortal.api.*;
|
||||
import org.qortal.api.model.SimpleTransactionSignRequest;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.controller.LiteNode;
|
||||
@@ -709,7 +704,7 @@ public class TransactionsResource {
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "true if accepted, false otherwise",
|
||||
description = "For API version 1, this returns true if accepted.\nFor API version 2, the transactionData is returned as a JSON string if accepted.",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
@@ -722,7 +717,9 @@ public class TransactionsResource {
|
||||
@ApiErrors({
|
||||
ApiError.BLOCKCHAIN_NEEDS_SYNC, ApiError.INVALID_SIGNATURE, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE
|
||||
})
|
||||
public String processTransaction(String rawBytes58) {
|
||||
public String processTransaction(String rawBytes58, @HeaderParam(ApiService.API_VERSION_HEADER) String apiVersionHeader) {
|
||||
int apiVersion = ApiService.getApiVersion(request);
|
||||
|
||||
// Only allow a transaction to be processed if our latest block is less than 60 minutes old
|
||||
// If older than this, we should first wait until the blockchain is synced
|
||||
final Long minLatestBlockTimestamp = NTP.getTime() - (60 * 60 * 1000L);
|
||||
@@ -759,13 +756,27 @@ public class TransactionsResource {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
|
||||
return "true";
|
||||
switch (apiVersion) {
|
||||
case 1:
|
||||
return "true";
|
||||
|
||||
case 2:
|
||||
default:
|
||||
// Marshall transactionData to string
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
ApiRequest.marshall(stringWriter, transactionData);
|
||||
return stringWriter.toString();
|
||||
}
|
||||
|
||||
|
||||
} catch (NumberFormatException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA, e);
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
} catch (InterruptedException e) {
|
||||
throw createTransactionInvalidException(request, ValidationResult.NO_BLOCKCHAIN_LOCK);
|
||||
} catch (IOException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
package org.qortal.api.resource;
|
||||
package org.qortal.api.restricted.resource;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
@@ -20,6 +20,7 @@ import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
@@ -31,10 +32,13 @@ import javax.ws.rs.*;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
|
||||
import org.apache.commons.lang3.reflect.FieldUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.apache.logging.log4j.core.LoggerContext;
|
||||
import org.apache.logging.log4j.core.appender.RollingFileAppender;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.qortal.account.Account;
|
||||
import org.qortal.account.PrivateKeyAccount;
|
||||
import org.qortal.api.*;
|
||||
@@ -42,9 +46,11 @@ import org.qortal.api.model.ActivitySummary;
|
||||
import org.qortal.api.model.NodeInfo;
|
||||
import org.qortal.api.model.NodeStatus;
|
||||
import org.qortal.block.BlockChain;
|
||||
import org.qortal.controller.AutoUpdate;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.controller.Synchronizer;
|
||||
import org.qortal.controller.Synchronizer.SynchronizationResult;
|
||||
import org.qortal.controller.repository.BlockArchiveRebuilder;
|
||||
import org.qortal.data.account.MintingAccountData;
|
||||
import org.qortal.data.account.RewardShareData;
|
||||
import org.qortal.network.Network;
|
||||
@@ -152,6 +158,53 @@ public class AdminResource {
|
||||
return nodeStatus;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/settings")
|
||||
@Operation(
|
||||
summary = "Fetch node settings",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Settings.class))
|
||||
)
|
||||
}
|
||||
)
|
||||
public Settings settings() {
|
||||
Settings nodeSettings = Settings.getInstance();
|
||||
|
||||
return nodeSettings;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/settings/{setting}")
|
||||
@Operation(
|
||||
summary = "Fetch a single node setting",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
public String setting(@PathParam("setting") String setting) {
|
||||
try {
|
||||
Object settingValue = FieldUtils.readField(Settings.getInstance(), setting, true);
|
||||
if (settingValue == null) {
|
||||
return "null";
|
||||
}
|
||||
else if (settingValue instanceof String[]) {
|
||||
JSONArray array = new JSONArray(settingValue);
|
||||
return array.toString(4);
|
||||
}
|
||||
else if (settingValue instanceof List) {
|
||||
JSONArray array = new JSONArray((List<Object>) settingValue);
|
||||
return array.toString(4);
|
||||
}
|
||||
|
||||
return settingValue.toString();
|
||||
} catch (IllegalAccessException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/stop")
|
||||
@Operation(
|
||||
@@ -182,6 +235,37 @@ public class AdminResource {
|
||||
return "true";
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/restart")
|
||||
@Operation(
|
||||
summary = "Restart",
|
||||
description = "Restart",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "\"true\"",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String restart(@HeaderParam(Security.API_KEY_HEADER) String apiKey) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
new Thread(() -> {
|
||||
// Short sleep to allow HTTP response body to be emitted
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
// Not important
|
||||
}
|
||||
|
||||
AutoUpdate.attemptRestart();
|
||||
|
||||
}).start();
|
||||
|
||||
return "true";
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/summary")
|
||||
@Operation(
|
||||
@@ -222,6 +306,42 @@ public class AdminResource {
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/summary/alltime")
|
||||
@Operation(
|
||||
summary = "Summary of activity since genesis",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
content = @Content(schema = @Schema(implementation = ActivitySummary.class))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public ActivitySummary allTimeSummary(@HeaderParam(Security.API_KEY_HEADER) String apiKey) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
ActivitySummary summary = new ActivitySummary();
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
int startHeight = 1;
|
||||
long start = repository.getBlockRepository().fromHeight(startHeight).getTimestamp();
|
||||
int endHeight = repository.getBlockRepository().getBlockchainHeight();
|
||||
|
||||
summary.setBlockCount(endHeight - startHeight);
|
||||
|
||||
summary.setTransactionCountByType(repository.getTransactionRepository().getTransactionSummary(startHeight + 1, endHeight));
|
||||
|
||||
summary.setAssetsIssued(repository.getAssetRepository().getRecentAssetIds(start).size());
|
||||
|
||||
summary.setNamesRegistered (repository.getNameRepository().getRecentNames(start).size());
|
||||
|
||||
return summary;
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/enginestats")
|
||||
@Operation(
|
||||
@@ -698,6 +818,64 @@ public class AdminResource {
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/repository/archive/rebuild")
|
||||
@Operation(
|
||||
summary = "Rebuild archive",
|
||||
description = "Rebuilds archive files, using the specified serialization version",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "number", example = "2"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "\"true\"",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||
)
|
||||
}
|
||||
)
|
||||
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String rebuildArchive(@HeaderParam(Security.API_KEY_HEADER) String apiKey, Integer serializationVersion) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
|
||||
// Default serialization version to value specified in settings
|
||||
if (serializationVersion == null) {
|
||||
serializationVersion = Settings.getInstance().getDefaultArchiveVersion();
|
||||
}
|
||||
|
||||
try {
|
||||
// We don't actually need to lock the blockchain here, but we'll do it anyway so that
|
||||
// the node can focus on rebuilding rather than synchronizing / minting.
|
||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||
|
||||
blockchainLock.lockInterruptibly();
|
||||
|
||||
try {
|
||||
BlockArchiveRebuilder blockArchiveRebuilder = new BlockArchiveRebuilder(serializationVersion);
|
||||
blockArchiveRebuilder.start();
|
||||
|
||||
return "true";
|
||||
|
||||
} catch (IOException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e);
|
||||
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
// We couldn't lock blockchain to perform rebuild
|
||||
return "false";
|
||||
} catch (DataException e) {
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||
}
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/repository")
|
||||
@Operation(
|
@@ -1,4 +1,4 @@
|
||||
package org.qortal.api.resource;
|
||||
package org.qortal.api.restricted.resource;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
@@ -60,7 +60,7 @@ public class BootstrapResource {
|
||||
bootstrap.validateBlockchain();
|
||||
return bootstrap.create();
|
||||
|
||||
} catch (DataException | InterruptedException | IOException e) {
|
||||
} catch (Exception e) {
|
||||
LOGGER.info("Unable to create bootstrap", e);
|
||||
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage());
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
package org.qortal.api.resource;
|
||||
package org.qortal.api.restricted.resource;
|
||||
|
||||
import javax.servlet.ServletContext;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
@@ -8,7 +8,6 @@ import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import java.io.*;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Map;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
@@ -28,8 +27,8 @@ import org.qortal.arbitrary.exception.MissingDataException;
|
||||
import org.qortal.controller.arbitrary.ArbitraryDataRenderManager;
|
||||
import org.qortal.data.transaction.ArbitraryTransactionData.*;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.arbitrary.ArbitraryDataFile.*;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
|
||||
@@ -43,60 +42,6 @@ public class RenderResource {
|
||||
@Context HttpServletResponse response;
|
||||
@Context ServletContext context;
|
||||
|
||||
@POST
|
||||
@Path("/preview")
|
||||
@Operation(
|
||||
summary = "Generate preview URL based on a user-supplied path and service",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string", example = "/Users/user/Documents/MyStaticWebsite"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "a temporary URL to preview the website",
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public String preview(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String directoryPath) {
|
||||
Security.checkApiCallAllowed(request);
|
||||
Method method = Method.PUT;
|
||||
Compression compression = Compression.ZIP;
|
||||
|
||||
ArbitraryDataWriter arbitraryDataWriter = new ArbitraryDataWriter(Paths.get(directoryPath),
|
||||
null, Service.WEBSITE, null, method, compression,
|
||||
null, null, null, null);
|
||||
try {
|
||||
arbitraryDataWriter.save();
|
||||
} catch (IOException | DataException | InterruptedException | MissingDataException e) {
|
||||
LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage());
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE);
|
||||
} catch (RuntimeException e) {
|
||||
LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage());
|
||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||
}
|
||||
|
||||
ArbitraryDataFile arbitraryDataFile = arbitraryDataWriter.getArbitraryDataFile();
|
||||
if (arbitraryDataFile != null) {
|
||||
String digest58 = arbitraryDataFile.digest58();
|
||||
if (digest58 != null) {
|
||||
return "http://localhost:12393/render/hash/" + digest58 + "?secret=" + Base58.encode(arbitraryDataFile.getSecret());
|
||||
}
|
||||
}
|
||||
return "Unable to generate preview URL";
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/authorize/{resourceId}")
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
@@ -140,8 +85,10 @@ public class RenderResource {
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public HttpServletResponse getIndexBySignature(@PathParam("signature") String signature,
|
||||
@QueryParam("theme") String theme) {
|
||||
Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null);
|
||||
return this.get(signature, ResourceIdType.SIGNATURE, null, "/", null, "/render/signature", true, true, theme);
|
||||
if (!Settings.getInstance().isQDNAuthBypassEnabled())
|
||||
Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null);
|
||||
|
||||
return this.get(signature, ResourceIdType.SIGNATURE, null, null, "/", null, "/render/signature", true, true, theme);
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -149,8 +96,10 @@ public class RenderResource {
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public HttpServletResponse getPathBySignature(@PathParam("signature") String signature, @PathParam("path") String inPath,
|
||||
@QueryParam("theme") String theme) {
|
||||
Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null);
|
||||
return this.get(signature, ResourceIdType.SIGNATURE, null, inPath,null, "/render/signature", true, true, theme);
|
||||
if (!Settings.getInstance().isQDNAuthBypassEnabled())
|
||||
Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null);
|
||||
|
||||
return this.get(signature, ResourceIdType.SIGNATURE, null, null, inPath,null, "/render/signature", true, true, theme);
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -158,8 +107,10 @@ public class RenderResource {
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public HttpServletResponse getIndexByHash(@PathParam("hash") String hash58, @QueryParam("secret") String secret58,
|
||||
@QueryParam("theme") String theme) {
|
||||
Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null);
|
||||
return this.get(hash58, ResourceIdType.FILE_HASH, Service.WEBSITE, "/", secret58, "/render/hash", true, false, theme);
|
||||
if (!Settings.getInstance().isQDNAuthBypassEnabled())
|
||||
Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null);
|
||||
|
||||
return this.get(hash58, ResourceIdType.FILE_HASH, Service.ARBITRARY_DATA, null, "/", secret58, "/render/hash", true, false, theme);
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -168,8 +119,10 @@ public class RenderResource {
|
||||
public HttpServletResponse getPathByHash(@PathParam("hash") String hash58, @PathParam("path") String inPath,
|
||||
@QueryParam("secret") String secret58,
|
||||
@QueryParam("theme") String theme) {
|
||||
Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null);
|
||||
return this.get(hash58, ResourceIdType.FILE_HASH, Service.WEBSITE, inPath, secret58, "/render/hash", true, false, theme);
|
||||
if (!Settings.getInstance().isQDNAuthBypassEnabled())
|
||||
Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null);
|
||||
|
||||
return this.get(hash58, ResourceIdType.FILE_HASH, Service.ARBITRARY_DATA, null, inPath, secret58, "/render/hash", true, false, theme);
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -178,10 +131,13 @@ public class RenderResource {
|
||||
public HttpServletResponse getPathByName(@PathParam("service") Service service,
|
||||
@PathParam("name") String name,
|
||||
@PathParam("path") String inPath,
|
||||
@QueryParam("identifier") String identifier,
|
||||
@QueryParam("theme") String theme) {
|
||||
Security.requirePriorAuthorization(request, name, service, null);
|
||||
if (!Settings.getInstance().isQDNAuthBypassEnabled())
|
||||
Security.requirePriorAuthorization(request, name, service, null);
|
||||
|
||||
String prefix = String.format("/render/%s", service);
|
||||
return this.get(name, ResourceIdType.NAME, service, inPath, null, prefix, true, true, theme);
|
||||
return this.get(name, ResourceIdType.NAME, service, identifier, inPath, null, prefix, true, true, theme);
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -189,19 +145,22 @@ public class RenderResource {
|
||||
@SecurityRequirement(name = "apiKey")
|
||||
public HttpServletResponse getIndexByName(@PathParam("service") Service service,
|
||||
@PathParam("name") String name,
|
||||
@QueryParam("identifier") String identifier,
|
||||
@QueryParam("theme") String theme) {
|
||||
Security.requirePriorAuthorization(request, name, service, null);
|
||||
if (!Settings.getInstance().isQDNAuthBypassEnabled())
|
||||
Security.requirePriorAuthorization(request, name, service, null);
|
||||
|
||||
String prefix = String.format("/render/%s", service);
|
||||
return this.get(name, ResourceIdType.NAME, service, "/", null, prefix, true, true, theme);
|
||||
return this.get(name, ResourceIdType.NAME, service, identifier, "/", null, prefix, true, true, theme);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String inPath,
|
||||
String secret58, String prefix, boolean usePrefix, boolean async, String theme) {
|
||||
private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier,
|
||||
String inPath, String secret58, String prefix, boolean usePrefix, boolean async, String theme) {
|
||||
|
||||
ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath,
|
||||
secret58, prefix, usePrefix, async, request, response, context);
|
||||
ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, identifier, inPath,
|
||||
secret58, prefix, usePrefix, async, "render", request, response, context);
|
||||
|
||||
if (theme != null) {
|
||||
renderer.setTheme(theme);
|
@@ -2,7 +2,9 @@ package org.qortal.api.websocket;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
@@ -21,6 +23,8 @@ import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
|
||||
import static org.qortal.data.chat.ChatMessage.Encoding;
|
||||
|
||||
@WebSocket
|
||||
@SuppressWarnings("serial")
|
||||
public class ActiveChatsWebSocket extends ApiWebSocket {
|
||||
@@ -62,7 +66,9 @@ public class ActiveChatsWebSocket extends ApiWebSocket {
|
||||
|
||||
@OnWebSocketMessage
|
||||
public void onWebSocketMessage(Session session, String message) {
|
||||
/* ignored */
|
||||
if (Objects.equals(message, "ping")) {
|
||||
session.getRemote().sendStringByFuture("pong");
|
||||
}
|
||||
}
|
||||
|
||||
private void onNotify(Session session, ChatTransactionData chatTransactionData, String ourAddress, AtomicReference<String> previousOutput) {
|
||||
@@ -75,7 +81,7 @@ public class ActiveChatsWebSocket extends ApiWebSocket {
|
||||
}
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
ActiveChats activeChats = repository.getChatRepository().getActiveChats(ourAddress);
|
||||
ActiveChats activeChats = repository.getChatRepository().getActiveChats(ourAddress, getTargetEncoding(session));
|
||||
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
|
||||
@@ -93,4 +99,12 @@ public class ActiveChatsWebSocket extends ApiWebSocket {
|
||||
}
|
||||
}
|
||||
|
||||
private Encoding getTargetEncoding(Session session) {
|
||||
// Default to Base58 if not specified, for backwards support
|
||||
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
|
||||
List<String> encodingList = queryParams.get("encoding");
|
||||
String encoding = (encodingList != null && encodingList.size() == 1) ? encodingList.get(0) : "BASE58";
|
||||
return Encoding.valueOf(encoding);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -2,10 +2,7 @@ package org.qortal.api.websocket;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.eclipse.jetty.websocket.api.WebSocketException;
|
||||
@@ -22,6 +19,8 @@ import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
|
||||
import static org.qortal.data.chat.ChatMessage.Encoding;
|
||||
|
||||
@WebSocket
|
||||
@SuppressWarnings("serial")
|
||||
public class ChatMessagesWebSocket extends ApiWebSocket {
|
||||
@@ -35,6 +34,16 @@ public class ChatMessagesWebSocket extends ApiWebSocket {
|
||||
@Override
|
||||
public void onWebSocketConnect(Session session) {
|
||||
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
|
||||
Encoding encoding = getTargetEncoding(session);
|
||||
|
||||
List<String> limitList = queryParams.get("limit");
|
||||
Integer limit = (limitList != null && limitList.size() == 1) ? Integer.parseInt(limitList.get(0)) : null;
|
||||
|
||||
List<String> offsetList = queryParams.get("offset");
|
||||
Integer offset = (offsetList != null && offsetList.size() == 1) ? Integer.parseInt(offsetList.get(0)) : null;
|
||||
|
||||
List<String> reverseList = queryParams.get("offset");
|
||||
Boolean reverse = (reverseList != null && reverseList.size() == 1) ? Boolean.getBoolean(reverseList.get(0)) : null;
|
||||
|
||||
List<String> txGroupIds = queryParams.get("txGroupId");
|
||||
if (txGroupIds != null && txGroupIds.size() == 1) {
|
||||
@@ -47,7 +56,11 @@ public class ChatMessagesWebSocket extends ApiWebSocket {
|
||||
txGroupId,
|
||||
null,
|
||||
null,
|
||||
null, null, null);
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
encoding,
|
||||
limit, offset, reverse);
|
||||
|
||||
sendMessages(session, chatMessages);
|
||||
} catch (DataException e) {
|
||||
@@ -74,8 +87,12 @@ public class ChatMessagesWebSocket extends ApiWebSocket {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
involvingAddresses,
|
||||
null, null, null);
|
||||
null,
|
||||
encoding,
|
||||
limit, offset, reverse);
|
||||
|
||||
sendMessages(session, chatMessages);
|
||||
} catch (DataException e) {
|
||||
@@ -101,7 +118,9 @@ public class ChatMessagesWebSocket extends ApiWebSocket {
|
||||
|
||||
@OnWebSocketMessage
|
||||
public void onWebSocketMessage(Session session, String message) {
|
||||
/* ignored */
|
||||
if (Objects.equals(message, "ping")) {
|
||||
session.getRemote().sendStringByFuture("pong");
|
||||
}
|
||||
}
|
||||
|
||||
private void onNotify(Session session, ChatTransactionData chatTransactionData, int txGroupId) {
|
||||
@@ -149,7 +168,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket {
|
||||
// Convert ChatTransactionData to ChatMessage
|
||||
ChatMessage chatMessage;
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
chatMessage = repository.getChatRepository().toChatMessage(chatTransactionData);
|
||||
chatMessage = repository.getChatRepository().toChatMessage(chatTransactionData, getTargetEncoding(session));
|
||||
} catch (DataException e) {
|
||||
// No output this time?
|
||||
return;
|
||||
@@ -158,4 +177,12 @@ public class ChatMessagesWebSocket extends ApiWebSocket {
|
||||
sendMessages(session, Collections.singletonList(chatMessage));
|
||||
}
|
||||
|
||||
private Encoding getTargetEncoding(Session session) {
|
||||
// Default to Base58 if not specified, for backwards support
|
||||
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
|
||||
List<String> encodingList = queryParams.get("encoding");
|
||||
String encoding = (encodingList != null && encodingList.size() == 1) ? encodingList.get(0) : "BASE58";
|
||||
return Encoding.valueOf(encoding);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ package org.qortal.arbitrary;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.arbitrary.exception.DataNotPublishedException;
|
||||
import org.qortal.arbitrary.exception.MissingDataException;
|
||||
import org.qortal.arbitrary.metadata.ArbitraryDataMetadataCache;
|
||||
import org.qortal.arbitrary.misc.Service;
|
||||
@@ -53,10 +54,6 @@ public class ArbitraryDataBuilder {
|
||||
/**
|
||||
* Process transactions, but do not build anything
|
||||
* This is useful for checking the status of a given resource
|
||||
*
|
||||
* @throws DataException
|
||||
* @throws IOException
|
||||
* @throws MissingDataException
|
||||
*/
|
||||
public void process() throws DataException, IOException, MissingDataException {
|
||||
this.fetchTransactions();
|
||||
@@ -68,10 +65,6 @@ public class ArbitraryDataBuilder {
|
||||
|
||||
/**
|
||||
* Build the latest state of a given resource
|
||||
*
|
||||
* @throws DataException
|
||||
* @throws IOException
|
||||
* @throws MissingDataException
|
||||
*/
|
||||
public void build() throws DataException, IOException, MissingDataException {
|
||||
this.process();
|
||||
@@ -88,7 +81,7 @@ public class ArbitraryDataBuilder {
|
||||
if (latestPut == null) {
|
||||
String message = String.format("Couldn't find PUT transaction for name %s, service %s and identifier %s",
|
||||
this.name, this.service, this.identifierString());
|
||||
throw new DataException(message);
|
||||
throw new DataNotPublishedException(message);
|
||||
}
|
||||
this.latestPutTransaction = latestPut;
|
||||
|
||||
|
@@ -4,6 +4,7 @@ import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.transaction.ArbitraryTransactionData;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.utils.Base58;
|
||||
@@ -15,7 +16,6 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static java.util.Arrays.stream;
|
||||
import static java.util.stream.Collectors.toMap;
|
||||
@@ -79,17 +79,31 @@ public class ArbitraryDataFile {
|
||||
this.signature = signature;
|
||||
}
|
||||
|
||||
public ArbitraryDataFile(byte[] fileContent, byte[] signature) throws DataException {
|
||||
public ArbitraryDataFile(byte[] fileContent, byte[] signature, boolean useTemporaryFile) throws DataException {
|
||||
if (fileContent == null) {
|
||||
LOGGER.error("fileContent is null");
|
||||
return;
|
||||
}
|
||||
|
||||
this.chunks = new ArrayList<>();
|
||||
this.hash58 = Base58.encode(Crypto.digest(fileContent));
|
||||
this.signature = signature;
|
||||
LOGGER.trace(String.format("File digest: %s, size: %d bytes", this.hash58, fileContent.length));
|
||||
|
||||
Path outputFilePath = getOutputFilePath(this.hash58, signature, true);
|
||||
Path outputFilePath;
|
||||
if (useTemporaryFile) {
|
||||
try {
|
||||
outputFilePath = Files.createTempFile("qortalRawData", null);
|
||||
outputFilePath.toFile().deleteOnExit();
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new DataException(String.format("Unable to write data with hash %s to temporary file: %s", this.hash58, e.getMessage()));
|
||||
}
|
||||
}
|
||||
else {
|
||||
outputFilePath = getOutputFilePath(this.hash58, signature, true);
|
||||
}
|
||||
|
||||
File outputFile = outputFilePath.toFile();
|
||||
try (FileOutputStream outputStream = new FileOutputStream(outputFile)) {
|
||||
outputStream.write(fileContent);
|
||||
@@ -111,6 +125,41 @@ public class ArbitraryDataFile {
|
||||
return ArbitraryDataFile.fromHash58(Base58.encode(hash), signature);
|
||||
}
|
||||
|
||||
public static ArbitraryDataFile fromRawData(byte[] data, byte[] signature) throws DataException {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
return new ArbitraryDataFile(data, signature, true);
|
||||
}
|
||||
|
||||
public static ArbitraryDataFile fromTransactionData(ArbitraryTransactionData transactionData) throws DataException {
|
||||
ArbitraryDataFile arbitraryDataFile = null;
|
||||
byte[] signature = transactionData.getSignature();
|
||||
byte[] data = transactionData.getData();
|
||||
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create data file
|
||||
switch (transactionData.getDataType()) {
|
||||
case DATA_HASH:
|
||||
arbitraryDataFile = ArbitraryDataFile.fromHash(data, signature);
|
||||
break;
|
||||
|
||||
case RAW_DATA:
|
||||
arbitraryDataFile = ArbitraryDataFile.fromRawData(data, signature);
|
||||
break;
|
||||
}
|
||||
|
||||
// Set metadata hash
|
||||
if (arbitraryDataFile != null) {
|
||||
arbitraryDataFile.setMetadataHash(transactionData.getMetadataHash());
|
||||
}
|
||||
|
||||
return arbitraryDataFile;
|
||||
}
|
||||
|
||||
public static ArbitraryDataFile fromPath(Path path, byte[] signature) {
|
||||
if (path == null) {
|
||||
return null;
|
||||
@@ -260,6 +309,11 @@ public class ArbitraryDataFile {
|
||||
this.chunks = new ArrayList<>();
|
||||
|
||||
if (file != null) {
|
||||
if (file.exists() && file.length() <= chunkSize) {
|
||||
// No need to split into chunks if we're already below the chunk size
|
||||
return 0;
|
||||
}
|
||||
|
||||
try (FileInputStream fileInputStream = new FileInputStream(file);
|
||||
BufferedInputStream bis = new BufferedInputStream(fileInputStream)) {
|
||||
|
||||
@@ -388,12 +442,15 @@ public class ArbitraryDataFile {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean deleteAll() {
|
||||
public boolean deleteAll(boolean deleteMetadata) {
|
||||
// Delete the complete file
|
||||
boolean fileDeleted = this.delete();
|
||||
|
||||
// Delete the metadata file
|
||||
boolean metadataDeleted = this.deleteMetadata();
|
||||
// Delete the metadata file if requested
|
||||
boolean metadataDeleted = false;
|
||||
if (deleteMetadata) {
|
||||
metadataDeleted = this.deleteMetadata();
|
||||
}
|
||||
|
||||
// Delete the individual chunks
|
||||
boolean chunksDeleted = this.deleteAllChunks();
|
||||
@@ -612,6 +669,22 @@ public class ArbitraryDataFile {
|
||||
return this.chunks.size();
|
||||
}
|
||||
|
||||
public int fileCount() {
|
||||
int fileCount = this.chunkCount();
|
||||
|
||||
if (fileCount == 0) {
|
||||
// Transactions without any chunks can already be treated as a complete file
|
||||
fileCount++;
|
||||
}
|
||||
|
||||
if (this.getMetadataHash() != null) {
|
||||
// Add the metadata file
|
||||
fileCount++;
|
||||
}
|
||||
|
||||
return fileCount;
|
||||
}
|
||||
|
||||
public List<ArbitraryDataFileChunk> getChunks() {
|
||||
return this.chunks;
|
||||
}
|
||||
|
@@ -18,7 +18,7 @@ public class ArbitraryDataFileChunk extends ArbitraryDataFile {
|
||||
}
|
||||
|
||||
public ArbitraryDataFileChunk(byte[] fileContent, byte[] signature) throws DataException {
|
||||
super(fileContent, signature);
|
||||
super(fileContent, signature, false);
|
||||
}
|
||||
|
||||
public static ArbitraryDataFileChunk fromHash58(String hash58, byte[] signature) throws DataException {
|
||||
|
@@ -4,11 +4,11 @@ import org.apache.commons.io.FileUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import org.qortal.arbitrary.exception.DataNotPublishedException;
|
||||
import org.qortal.arbitrary.exception.MissingDataException;
|
||||
import org.qortal.arbitrary.misc.Service;
|
||||
import org.qortal.controller.arbitrary.ArbitraryDataBuildManager;
|
||||
import org.qortal.controller.arbitrary.ArbitraryDataManager;
|
||||
import org.qortal.controller.arbitrary.ArbitraryDataStorageManager;
|
||||
import org.qortal.crypto.AES;
|
||||
import org.qortal.data.transaction.ArbitraryTransactionData;
|
||||
import org.qortal.data.transaction.ArbitraryTransactionData.*;
|
||||
@@ -18,10 +18,7 @@ import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.arbitrary.ArbitraryDataFile.*;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transform.Transformer;
|
||||
import org.qortal.utils.ArbitraryTransactionUtils;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.FilesystemUtils;
|
||||
import org.qortal.utils.ZipUtils;
|
||||
import org.qortal.utils.*;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
@@ -37,6 +34,9 @@ import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ArbitraryDataReader {
|
||||
|
||||
@@ -59,6 +59,13 @@ public class ArbitraryDataReader {
|
||||
private int layerCount;
|
||||
private byte[] latestSignature;
|
||||
|
||||
// The resource being read
|
||||
ArbitraryDataResource arbitraryDataResource = null;
|
||||
|
||||
// Track resources that are currently being loaded, to avoid duplicate concurrent builds
|
||||
// TODO: all builds could be handled by the build queue (even synchronous ones), to avoid the need for this
|
||||
private static Map<String, Long> inProgress = Collections.synchronizedMap(new HashMap<>());
|
||||
|
||||
public ArbitraryDataReader(String resourceId, ResourceIdType resourceIdType, Service service, String identifier) {
|
||||
// Ensure names are always lowercase
|
||||
if (resourceIdType == ResourceIdType.NAME) {
|
||||
@@ -115,6 +122,11 @@ public class ArbitraryDataReader {
|
||||
return new ArbitraryDataBuildQueueItem(this.resourceId, this.resourceIdType, this.service, this.identifier);
|
||||
}
|
||||
|
||||
private ArbitraryDataResource createArbitraryDataResource() {
|
||||
return new ArbitraryDataResource(this.resourceId, this.resourceIdType, this.service, this.identifier);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* loadAsynchronously
|
||||
*
|
||||
@@ -148,9 +160,6 @@ public class ArbitraryDataReader {
|
||||
* If no exception is thrown, you can then use getFilePath() to access the data immediately after returning
|
||||
*
|
||||
* @param overwrite - set to true to force rebuild an existing cache
|
||||
* @throws IOException
|
||||
* @throws DataException
|
||||
* @throws MissingDataException
|
||||
*/
|
||||
public void loadSynchronously(boolean overwrite) throws DataException, IOException, MissingDataException {
|
||||
try {
|
||||
@@ -162,6 +171,14 @@ public class ArbitraryDataReader {
|
||||
return;
|
||||
}
|
||||
|
||||
this.arbitraryDataResource = this.createArbitraryDataResource();
|
||||
|
||||
// Don't allow duplicate loads
|
||||
if (!this.canStartLoading()) {
|
||||
LOGGER.debug("Skipping duplicate load of {}", this.arbitraryDataResource);
|
||||
return;
|
||||
}
|
||||
|
||||
this.preExecute();
|
||||
this.deleteExistingFiles();
|
||||
this.fetch();
|
||||
@@ -169,10 +186,18 @@ public class ArbitraryDataReader {
|
||||
this.uncompress();
|
||||
this.validate();
|
||||
|
||||
} catch (DataNotPublishedException e) {
|
||||
if (e.getMessage() != null) {
|
||||
// Log the message only, to avoid spamming the logs with a full stack trace
|
||||
LOGGER.debug("DataNotPublishedException when trying to load QDN resource: {}", e.getMessage());
|
||||
}
|
||||
this.deleteWorkingDirectory();
|
||||
throw e;
|
||||
|
||||
} catch (DataException e) {
|
||||
LOGGER.info("DataException when trying to load QDN resource", e);
|
||||
this.deleteWorkingDirectory();
|
||||
throw new DataException(e.getMessage());
|
||||
throw e;
|
||||
|
||||
} finally {
|
||||
this.postExecute();
|
||||
@@ -181,6 +206,7 @@ public class ArbitraryDataReader {
|
||||
|
||||
private void preExecute() throws DataException {
|
||||
ArbitraryDataBuildManager.getInstance().setBuildInProgress(true);
|
||||
|
||||
this.checkEnabled();
|
||||
this.createWorkingDirectory();
|
||||
this.createUncompressedDirectory();
|
||||
@@ -188,6 +214,9 @@ public class ArbitraryDataReader {
|
||||
|
||||
private void postExecute() {
|
||||
ArbitraryDataBuildManager.getInstance().setBuildInProgress(false);
|
||||
|
||||
this.arbitraryDataResource = this.createArbitraryDataResource();
|
||||
ArbitraryDataReader.inProgress.remove(this.arbitraryDataResource.getUniqueKey());
|
||||
}
|
||||
|
||||
private void checkEnabled() throws DataException {
|
||||
@@ -196,6 +225,17 @@ public class ArbitraryDataReader {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canStartLoading() {
|
||||
// Avoid duplicate builds if we're already loading this resource
|
||||
String uniqueKey = this.arbitraryDataResource.getUniqueKey();
|
||||
if (ArbitraryDataReader.inProgress.containsKey(uniqueKey)) {
|
||||
return false;
|
||||
}
|
||||
ArbitraryDataReader.inProgress.put(uniqueKey, NTP.getTime());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void createWorkingDirectory() throws DataException {
|
||||
try {
|
||||
Files.createDirectories(this.workingPath);
|
||||
@@ -207,7 +247,6 @@ public class ArbitraryDataReader {
|
||||
/**
|
||||
* Working directory should only be deleted on failure, since it is currently used to
|
||||
* serve a cached version of the resource for subsequent requests.
|
||||
* @throws IOException
|
||||
*/
|
||||
private void deleteWorkingDirectory() {
|
||||
try {
|
||||
@@ -287,7 +326,7 @@ public class ArbitraryDataReader {
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new DataException(String.format("Unknown resource ID type specified: %s", resourceIdType.toString()));
|
||||
throw new DataException(String.format("Unknown resource ID type specified: %s", resourceIdType));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,11 +382,6 @@ public class ArbitraryDataReader {
|
||||
throw new DataException(String.format("Transaction data not found for signature %s", this.resourceId));
|
||||
}
|
||||
|
||||
// Load hashes
|
||||
byte[] digest = transactionData.getData();
|
||||
byte[] metadataHash = transactionData.getMetadataHash();
|
||||
byte[] signature = transactionData.getSignature();
|
||||
|
||||
// Load secret
|
||||
byte[] secret = transactionData.getSecret();
|
||||
if (secret != null) {
|
||||
@@ -355,16 +389,17 @@ public class ArbitraryDataReader {
|
||||
}
|
||||
|
||||
// Load data file(s)
|
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(digest, signature);
|
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData);
|
||||
ArbitraryTransactionUtils.checkAndRelocateMiscFiles(transactionData);
|
||||
arbitraryDataFile.setMetadataHash(metadataHash);
|
||||
if (arbitraryDataFile == null) {
|
||||
throw new DataException(String.format("arbitraryDataFile is null"));
|
||||
}
|
||||
|
||||
if (!arbitraryDataFile.allFilesExist()) {
|
||||
if (ArbitraryDataStorageManager.getInstance().isNameBlocked(transactionData.getName())) {
|
||||
if (ListUtils.isNameBlocked(transactionData.getName())) {
|
||||
throw new DataException(
|
||||
String.format("Unable to request missing data for file %s because the name is blocked", arbitraryDataFile));
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
// Ask the arbitrary data manager to fetch data for this transaction
|
||||
String message;
|
||||
if (this.canRequestMissingFiles) {
|
||||
@@ -375,8 +410,7 @@ public class ArbitraryDataReader {
|
||||
} else {
|
||||
message = String.format("Unable to reissue request for missing file %s for signature %s due to rate limit. Please try again later.", arbitraryDataFile, Base58.encode(transactionData.getSignature()));
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
message = String.format("Missing data for file %s", arbitraryDataFile);
|
||||
}
|
||||
|
||||
@@ -386,21 +420,25 @@ public class ArbitraryDataReader {
|
||||
}
|
||||
}
|
||||
|
||||
if (arbitraryDataFile.allChunksExist() && !arbitraryDataFile.exists()) {
|
||||
// We have all the chunks but not the complete file, so join them
|
||||
arbitraryDataFile.join();
|
||||
// Data hashes need some extra processing
|
||||
if (transactionData.getDataType() == DataType.DATA_HASH) {
|
||||
if (arbitraryDataFile.allChunksExist() && !arbitraryDataFile.exists()) {
|
||||
// We have all the chunks but not the complete file, so join them
|
||||
arbitraryDataFile.join();
|
||||
}
|
||||
|
||||
// If the complete file still doesn't exist then something went wrong
|
||||
if (!arbitraryDataFile.exists()) {
|
||||
throw new IOException(String.format("File doesn't exist: %s", arbitraryDataFile));
|
||||
}
|
||||
// Ensure the complete hash matches the joined chunks
|
||||
if (!Arrays.equals(arbitraryDataFile.digest(), transactionData.getData())) {
|
||||
// Delete the invalid file
|
||||
arbitraryDataFile.delete();
|
||||
throw new DataException("Unable to validate complete file hash");
|
||||
}
|
||||
}
|
||||
|
||||
// If the complete file still doesn't exist then something went wrong
|
||||
if (!arbitraryDataFile.exists()) {
|
||||
throw new IOException(String.format("File doesn't exist: %s", arbitraryDataFile));
|
||||
}
|
||||
// Ensure the complete hash matches the joined chunks
|
||||
if (!Arrays.equals(arbitraryDataFile.digest(), digest)) {
|
||||
// Delete the invalid file
|
||||
arbitraryDataFile.delete();
|
||||
throw new DataException("Unable to validate complete file hash");
|
||||
}
|
||||
// Ensure the file's size matches the size reported by the transaction (throws a DataException if not)
|
||||
arbitraryDataFile.validateFileSize(transactionData.getSize());
|
||||
|
||||
@@ -427,10 +465,11 @@ public class ArbitraryDataReader {
|
||||
byte[] secret = this.secret58 != null ? Base58.decode(this.secret58) : null;
|
||||
if (secret != null && secret.length == Transformer.AES256_LENGTH) {
|
||||
try {
|
||||
LOGGER.info("Decrypting using algorithm {}...", algorithm);
|
||||
LOGGER.debug("Decrypting {} using algorithm {}...", this.arbitraryDataResource, algorithm);
|
||||
Path unencryptedPath = Paths.get(this.workingPath.toString(), "zipped.zip");
|
||||
SecretKey aesKey = new SecretKeySpec(secret, 0, secret.length, "AES");
|
||||
AES.decryptFile(algorithm, aesKey, this.filePath.toString(), unencryptedPath.toString());
|
||||
LOGGER.debug("Finished decrypting {} using algorithm {}", this.arbitraryDataResource, algorithm);
|
||||
|
||||
// Replace filePath pointer with the encrypted file path
|
||||
// Don't delete the original ArbitraryDataFile, as this is handled in the cleanup phase
|
||||
@@ -438,7 +477,7 @@ public class ArbitraryDataReader {
|
||||
|
||||
} catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | NoSuchPaddingException
|
||||
| BadPaddingException | IllegalBlockSizeException | IOException | InvalidKeyException e) {
|
||||
LOGGER.info(String.format("Exception when decrypting using algorithm %s", algorithm), e);
|
||||
LOGGER.info(String.format("Exception when decrypting %s using algorithm %s", this.arbitraryDataResource, algorithm), e);
|
||||
throw new DataException(String.format("Unable to decrypt file at path %s using algorithm %s: %s", this.filePath, algorithm, e.getMessage()));
|
||||
}
|
||||
} else {
|
||||
@@ -465,7 +504,9 @@ public class ArbitraryDataReader {
|
||||
|
||||
// Handle each type of compression
|
||||
if (compression == Compression.ZIP) {
|
||||
LOGGER.debug("Unzipping {}...", this.arbitraryDataResource);
|
||||
ZipUtils.unzip(this.filePath.toString(), this.uncompressedPath.getParent().toString());
|
||||
LOGGER.debug("Finished unzipping {}", this.arbitraryDataResource);
|
||||
}
|
||||
else if (compression == Compression.NONE) {
|
||||
Files.createDirectories(this.uncompressedPath);
|
||||
@@ -501,10 +542,12 @@ public class ArbitraryDataReader {
|
||||
|
||||
private void validate() throws IOException, DataException {
|
||||
if (this.service.isValidationRequired()) {
|
||||
LOGGER.debug("Validating {}...", this.arbitraryDataResource);
|
||||
Service.ValidationResult result = this.service.validate(this.filePath);
|
||||
if (result != Service.ValidationResult.OK) {
|
||||
throw new DataException(String.format("Validation of %s failed: %s", this.service, result.toString()));
|
||||
}
|
||||
LOGGER.debug("Finished validating {}", this.arbitraryDataResource);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -2,6 +2,7 @@ package org.qortal.arbitrary;
|
||||
|
||||
import com.google.common.io.Resources;
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.api.HTMLParser;
|
||||
@@ -34,36 +35,40 @@ public class ArbitraryDataRenderer {
|
||||
private final String resourceId;
|
||||
private final ResourceIdType resourceIdType;
|
||||
private final Service service;
|
||||
private final String identifier;
|
||||
private String theme = "light";
|
||||
private String inPath;
|
||||
private final String secret58;
|
||||
private final String prefix;
|
||||
private final boolean usePrefix;
|
||||
private final boolean async;
|
||||
private final String qdnContext;
|
||||
private final HttpServletRequest request;
|
||||
private final HttpServletResponse response;
|
||||
private final ServletContext context;
|
||||
|
||||
public ArbitraryDataRenderer(String resourceId, ResourceIdType resourceIdType, Service service, String inPath,
|
||||
String secret58, String prefix, boolean usePrefix, boolean async,
|
||||
public ArbitraryDataRenderer(String resourceId, ResourceIdType resourceIdType, Service service, String identifier,
|
||||
String inPath, String secret58, String prefix, boolean usePrefix, boolean async, String qdnContext,
|
||||
HttpServletRequest request, HttpServletResponse response, ServletContext context) {
|
||||
|
||||
this.resourceId = resourceId;
|
||||
this.resourceIdType = resourceIdType;
|
||||
this.service = service;
|
||||
this.identifier = identifier != null ? identifier : "default";
|
||||
this.inPath = inPath;
|
||||
this.secret58 = secret58;
|
||||
this.prefix = prefix;
|
||||
this.usePrefix = usePrefix;
|
||||
this.async = async;
|
||||
this.qdnContext = qdnContext;
|
||||
this.request = request;
|
||||
this.response = response;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public HttpServletResponse render() {
|
||||
if (!inPath.startsWith(File.separator)) {
|
||||
inPath = File.separator + inPath;
|
||||
if (!inPath.startsWith("/")) {
|
||||
inPath = "/" + inPath;
|
||||
}
|
||||
|
||||
// Don't render data if QDN is disabled
|
||||
@@ -71,14 +76,14 @@ public class ArbitraryDataRenderer {
|
||||
return ArbitraryDataRenderer.getResponse(response, 500, "QDN is disabled in settings");
|
||||
}
|
||||
|
||||
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(resourceId, resourceIdType, service, null);
|
||||
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(resourceId, resourceIdType, service, identifier);
|
||||
arbitraryDataReader.setSecret58(secret58); // Optional, used for loading encrypted file hashes only
|
||||
try {
|
||||
if (!arbitraryDataReader.isCachedDataAvailable()) {
|
||||
// If async is requested, show a loading screen whilst build is in progress
|
||||
if (async) {
|
||||
arbitraryDataReader.loadAsynchronously(false, 10);
|
||||
return this.getLoadingResponse(service, resourceId, theme);
|
||||
return this.getLoadingResponse(service, resourceId, identifier, theme);
|
||||
}
|
||||
|
||||
// Otherwise, loop until we have data
|
||||
@@ -111,23 +116,59 @@ public class ArbitraryDataRenderer {
|
||||
}
|
||||
String unzippedPath = path.toString();
|
||||
|
||||
// Set path automatically for single file resources (except for apps, which handle routing differently)
|
||||
String[] files = ArrayUtils.removeElement(new File(unzippedPath).list(), ".qortal");
|
||||
if (files.length == 1 && this.service != Service.APP) {
|
||||
// This is a single file resource
|
||||
inPath = files[0];
|
||||
}
|
||||
|
||||
try {
|
||||
String filename = this.getFilename(unzippedPath, inPath);
|
||||
String filePath = Paths.get(unzippedPath, filename).toString();
|
||||
Path filePath = Paths.get(unzippedPath, filename);
|
||||
boolean usingCustomRouting = false;
|
||||
|
||||
// If the file doesn't exist, we may need to route the request elsewhere, or cleanup
|
||||
if (!Files.exists(filePath)) {
|
||||
if (inPath.equals("/")) {
|
||||
// Delete the unzipped folder if no index file was found
|
||||
try {
|
||||
FileUtils.deleteDirectory(new File(unzippedPath));
|
||||
} catch (IOException e) {
|
||||
LOGGER.debug("Unable to delete directory: {}", unzippedPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
// If this is an app, then forward all unhandled requests to the index, to give the app the option to route it
|
||||
if (this.service == Service.APP) {
|
||||
// Locate index file
|
||||
List<String> indexFiles = ArbitraryDataRenderer.indexFiles();
|
||||
for (String indexFile : indexFiles) {
|
||||
Path indexPath = Paths.get(unzippedPath, indexFile);
|
||||
if (Files.exists(indexPath)) {
|
||||
// Forward request to index file
|
||||
filePath = indexPath;
|
||||
filename = indexFile;
|
||||
usingCustomRouting = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (HTMLParser.isHtmlFile(filename)) {
|
||||
// HTML file - needs to be parsed
|
||||
byte[] data = Files.readAllBytes(Paths.get(filePath)); // TODO: limit file size that can be read into memory
|
||||
HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data);
|
||||
byte[] data = Files.readAllBytes(filePath); // TODO: limit file size that can be read into memory
|
||||
HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data, qdnContext, service, identifier, theme, usingCustomRouting);
|
||||
htmlParser.addAdditionalHeaderTags();
|
||||
response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' blob:; img-src 'self' data: blob:;");
|
||||
response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' data: blob:; img-src 'self' data: blob:;");
|
||||
response.setContentType(context.getMimeType(filename));
|
||||
response.setContentLength(htmlParser.getData().length);
|
||||
response.getOutputStream().write(htmlParser.getData());
|
||||
}
|
||||
else {
|
||||
// Regular file - can be streamed directly
|
||||
File file = new File(filePath);
|
||||
File file = filePath.toFile();
|
||||
FileInputStream inputStream = new FileInputStream(file);
|
||||
response.addHeader("Content-Security-Policy", "default-src 'self'");
|
||||
response.setContentType(context.getMimeType(filename));
|
||||
@@ -143,14 +184,6 @@ public class ArbitraryDataRenderer {
|
||||
return response;
|
||||
} catch (FileNotFoundException | NoSuchFileException e) {
|
||||
LOGGER.info("Unable to serve file: {}", e.getMessage());
|
||||
if (inPath.equals("/")) {
|
||||
// Delete the unzipped folder if no index file was found
|
||||
try {
|
||||
FileUtils.deleteDirectory(new File(unzippedPath));
|
||||
} catch (IOException ioException) {
|
||||
LOGGER.debug("Unable to delete directory: {}", unzippedPath, e);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.info("Unable to serve file at path {}: {}", inPath, e.getMessage());
|
||||
}
|
||||
@@ -172,7 +205,7 @@ public class ArbitraryDataRenderer {
|
||||
return userPath;
|
||||
}
|
||||
|
||||
private HttpServletResponse getLoadingResponse(Service service, String name, String theme) {
|
||||
private HttpServletResponse getLoadingResponse(Service service, String name, String identifier, String theme) {
|
||||
String responseString = "";
|
||||
URL url = Resources.getResource("loading/index.html");
|
||||
try {
|
||||
@@ -181,6 +214,7 @@ public class ArbitraryDataRenderer {
|
||||
// Replace vars
|
||||
responseString = responseString.replace("%%SERVICE%%", service.toString());
|
||||
responseString = responseString.replace("%%NAME%%", name);
|
||||
responseString = responseString.replace("%%IDENTIFIER%%", identifier);
|
||||
responseString = responseString.replace("%%THEME%%", theme);
|
||||
|
||||
} catch (IOException e) {
|
||||
|
@@ -3,6 +3,7 @@ package org.qortal.arbitrary;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType;
|
||||
import org.qortal.arbitrary.exception.DataNotPublishedException;
|
||||
import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata;
|
||||
import org.qortal.arbitrary.misc.Service;
|
||||
import org.qortal.controller.arbitrary.ArbitraryDataBuildManager;
|
||||
@@ -10,13 +11,13 @@ import org.qortal.controller.arbitrary.ArbitraryDataManager;
|
||||
import org.qortal.controller.arbitrary.ArbitraryDataStorageManager;
|
||||
import org.qortal.data.arbitrary.ArbitraryResourceStatus;
|
||||
import org.qortal.data.transaction.ArbitraryTransactionData;
|
||||
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.FilesystemUtils;
|
||||
import org.qortal.utils.ListUtils;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -42,6 +43,7 @@ public class ArbitraryDataResource {
|
||||
private int layerCount;
|
||||
private Integer localChunkCount = null;
|
||||
private Integer totalChunkCount = null;
|
||||
private boolean exists = false;
|
||||
|
||||
public ArbitraryDataResource(String resourceId, ResourceIdType resourceIdType, Service service, String identifier) {
|
||||
this.resourceId = resourceId.toLowerCase();
|
||||
@@ -60,6 +62,10 @@ public class ArbitraryDataResource {
|
||||
// Avoid this for "quick" statuses, to speed things up
|
||||
if (!quick) {
|
||||
this.calculateChunkCounts();
|
||||
|
||||
if (!this.exists) {
|
||||
return new ArbitraryResourceStatus(Status.NOT_PUBLISHED, this.localChunkCount, this.totalChunkCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceIdType != ResourceIdType.NAME) {
|
||||
@@ -68,8 +74,7 @@ public class ArbitraryDataResource {
|
||||
}
|
||||
|
||||
// Check if the name is blocked
|
||||
if (ResourceListManager.getInstance()
|
||||
.listContains("blockedNames", this.resourceId, false)) {
|
||||
if (ListUtils.isNameBlocked(this.resourceId)) {
|
||||
return new ArbitraryResourceStatus(Status.BLOCKED, this.localChunkCount, this.totalChunkCount);
|
||||
}
|
||||
|
||||
@@ -134,21 +139,23 @@ public class ArbitraryDataResource {
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean delete() {
|
||||
public boolean delete(boolean deleteMetadata) {
|
||||
try {
|
||||
this.fetchTransactions();
|
||||
if (this.transactions == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<ArbitraryTransactionData> transactionDataList = new ArrayList<>(this.transactions);
|
||||
|
||||
for (ArbitraryTransactionData transactionData : transactionDataList) {
|
||||
byte[] hash = transactionData.getData();
|
||||
byte[] metadataHash = transactionData.getMetadataHash();
|
||||
byte[] signature = transactionData.getSignature();
|
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(hash, signature);
|
||||
arbitraryDataFile.setMetadataHash(metadataHash);
|
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData);
|
||||
if (arbitraryDataFile == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete any chunks or complete files from each transaction
|
||||
arbitraryDataFile.deleteAll();
|
||||
arbitraryDataFile.deleteAll(deleteMetadata);
|
||||
}
|
||||
|
||||
// Also delete cached data for the entire resource
|
||||
@@ -192,6 +199,9 @@ public class ArbitraryDataResource {
|
||||
|
||||
try {
|
||||
this.fetchTransactions();
|
||||
if (this.transactions == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<ArbitraryTransactionData> transactionDataList = new ArrayList<>(this.transactions);
|
||||
|
||||
@@ -211,6 +221,14 @@ public class ArbitraryDataResource {
|
||||
private void calculateChunkCounts() {
|
||||
try {
|
||||
this.fetchTransactions();
|
||||
if (this.transactions == null) {
|
||||
this.exists = false;
|
||||
this.localChunkCount = 0;
|
||||
this.totalChunkCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.exists = true;
|
||||
|
||||
List<ArbitraryTransactionData> transactionDataList = new ArrayList<>(this.transactions);
|
||||
int localChunkCount = 0;
|
||||
@@ -230,6 +248,9 @@ public class ArbitraryDataResource {
|
||||
private boolean isRateLimited() {
|
||||
try {
|
||||
this.fetchTransactions();
|
||||
if (this.transactions == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
List<ArbitraryTransactionData> transactionDataList = new ArrayList<>(this.transactions);
|
||||
|
||||
@@ -253,6 +274,10 @@ public class ArbitraryDataResource {
|
||||
private boolean isDataPotentiallyAvailable() {
|
||||
try {
|
||||
this.fetchTransactions();
|
||||
if (this.transactions == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Long now = NTP.getTime();
|
||||
if (now == null) {
|
||||
return false;
|
||||
@@ -284,6 +309,10 @@ public class ArbitraryDataResource {
|
||||
private boolean isDownloading() {
|
||||
try {
|
||||
this.fetchTransactions();
|
||||
if (this.transactions == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Long now = NTP.getTime();
|
||||
if (now == null) {
|
||||
return false;
|
||||
@@ -325,7 +354,7 @@ public class ArbitraryDataResource {
|
||||
if (latestPut == null) {
|
||||
String message = String.format("Couldn't find PUT transaction for name %s, service %s and identifier %s",
|
||||
this.resourceId, this.service, this.identifierString());
|
||||
throw new DataException(message);
|
||||
throw new DataNotPublishedException(message);
|
||||
}
|
||||
this.latestPutTransaction = latestPut;
|
||||
|
||||
@@ -336,7 +365,10 @@ public class ArbitraryDataResource {
|
||||
this.transactions = transactionDataList;
|
||||
this.layerCount = transactionDataList.size();
|
||||
|
||||
} catch (DataException e) {
|
||||
} catch (DataNotPublishedException e) {
|
||||
// Ignore without logging
|
||||
}
|
||||
catch (DataException e) {
|
||||
LOGGER.info(String.format("Repository error when fetching transactions for resource %s: %s", this, e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
@@ -9,6 +9,7 @@ import org.qortal.arbitrary.metadata.ArbitraryDataMetadataPatch;
|
||||
import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata;
|
||||
import org.qortal.arbitrary.misc.Category;
|
||||
import org.qortal.arbitrary.misc.Service;
|
||||
import org.qortal.crypto.AES;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.PaymentData;
|
||||
import org.qortal.data.transaction.ArbitraryTransactionData;
|
||||
@@ -46,6 +47,7 @@ public class ArbitraryDataTransactionBuilder {
|
||||
private static final double MAX_FILE_DIFF = 0.5f;
|
||||
|
||||
private final String publicKey58;
|
||||
private final long fee;
|
||||
private final Path path;
|
||||
private final String name;
|
||||
private Method method;
|
||||
@@ -64,11 +66,12 @@ public class ArbitraryDataTransactionBuilder {
|
||||
private ArbitraryTransactionData arbitraryTransactionData;
|
||||
private ArbitraryDataFile arbitraryDataFile;
|
||||
|
||||
public ArbitraryDataTransactionBuilder(Repository repository, String publicKey58, Path path, String name,
|
||||
public ArbitraryDataTransactionBuilder(Repository repository, String publicKey58, long fee, Path path, String name,
|
||||
Method method, Service service, String identifier,
|
||||
String title, String description, List<String> tags, Category category) {
|
||||
this.repository = repository;
|
||||
this.publicKey58 = publicKey58;
|
||||
this.fee = fee;
|
||||
this.path = path;
|
||||
this.name = name;
|
||||
this.method = method;
|
||||
@@ -179,6 +182,7 @@ public class ArbitraryDataTransactionBuilder {
|
||||
for (ModifiedPath path : metadata.getModifiedPaths()) {
|
||||
if (path.getDiffType() != DiffType.COMPLETE_FILE) {
|
||||
atLeastOnePatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,6 +191,14 @@ public class ArbitraryDataTransactionBuilder {
|
||||
return Method.PUT;
|
||||
}
|
||||
|
||||
// We can't use PATCH for on-chain data because this requires the .qortal directory, which can't be put on chain
|
||||
final boolean isSingleFileResource = FilesystemUtils.isSingleFileResource(this.path, false);
|
||||
final boolean shouldUseOnChainData = (isSingleFileResource && AES.getEncryptedFileSize(FilesystemUtils.getSingleFileContents(path).length) <= ArbitraryTransaction.MAX_DATA_SIZE);
|
||||
if (shouldUseOnChainData) {
|
||||
LOGGER.info("Data size is small enough to go on chain - using PUT");
|
||||
return Method.PUT;
|
||||
}
|
||||
|
||||
// State is appropriate for a PATCH transaction
|
||||
return Method.PATCH;
|
||||
}
|
||||
@@ -227,10 +239,12 @@ public class ArbitraryDataTransactionBuilder {
|
||||
random.nextBytes(lastReference);
|
||||
}
|
||||
|
||||
Compression compression = Compression.ZIP;
|
||||
// Single file resources are handled differently, especially for very small data payloads, as these go on chain
|
||||
final boolean isSingleFileResource = FilesystemUtils.isSingleFileResource(path, false);
|
||||
final boolean shouldUseOnChainData = (isSingleFileResource && AES.getEncryptedFileSize(FilesystemUtils.getSingleFileContents(path).length) <= ArbitraryTransaction.MAX_DATA_SIZE);
|
||||
|
||||
// FUTURE? Use zip compression for directories, or no compression for single files
|
||||
// Compression compression = (path.toFile().isDirectory()) ? Compression.ZIP : Compression.NONE;
|
||||
// Use zip compression if data isn't going on chain
|
||||
Compression compression = shouldUseOnChainData ? Compression.NONE : Compression.ZIP;
|
||||
|
||||
ArbitraryDataWriter arbitraryDataWriter = new ArbitraryDataWriter(path, name, service, identifier, method,
|
||||
compression, title, description, tags, category);
|
||||
@@ -248,45 +262,52 @@ public class ArbitraryDataTransactionBuilder {
|
||||
throw new DataException("Arbitrary data file is null");
|
||||
}
|
||||
|
||||
// Get chunks metadata file
|
||||
// Get metadata file
|
||||
ArbitraryDataFile metadataFile = arbitraryDataFile.getMetadataFile();
|
||||
if (metadataFile == null && arbitraryDataFile.chunkCount() > 1) {
|
||||
throw new DataException(String.format("Chunks metadata data file is null but there are %d chunks", arbitraryDataFile.chunkCount()));
|
||||
}
|
||||
|
||||
String digest58 = arbitraryDataFile.digest58();
|
||||
if (digest58 == null) {
|
||||
LOGGER.error("Unable to calculate file digest");
|
||||
throw new DataException("Unable to calculate file digest");
|
||||
// Default to using a data hash, with data held off-chain
|
||||
ArbitraryTransactionData.DataType dataType = ArbitraryTransactionData.DataType.DATA_HASH;
|
||||
byte[] data = arbitraryDataFile.digest();
|
||||
|
||||
// For small, single-chunk resources, we can store the data directly on chain
|
||||
if (shouldUseOnChainData && arbitraryDataFile.getBytes().length <= ArbitraryTransaction.MAX_DATA_SIZE && arbitraryDataFile.chunkCount() == 0) {
|
||||
// Within allowed on-chain data size
|
||||
dataType = DataType.RAW_DATA;
|
||||
data = arbitraryDataFile.getBytes();
|
||||
}
|
||||
|
||||
final BaseTransactionData baseTransactionData = new BaseTransactionData(now, Group.NO_GROUP,
|
||||
lastReference, creatorPublicKey, 0L, null);
|
||||
lastReference, creatorPublicKey, fee, null);
|
||||
final int size = (int) arbitraryDataFile.size();
|
||||
final int version = 5;
|
||||
final int nonce = 0;
|
||||
byte[] secret = arbitraryDataFile.getSecret();
|
||||
final ArbitraryTransactionData.DataType dataType = ArbitraryTransactionData.DataType.DATA_HASH;
|
||||
final byte[] digest = arbitraryDataFile.digest();
|
||||
|
||||
final byte[] metadataHash = (metadataFile != null) ? metadataFile.getHash() : null;
|
||||
final List<PaymentData> payments = new ArrayList<>();
|
||||
|
||||
ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData,
|
||||
version, service, nonce, size, name, identifier, method,
|
||||
secret, compression, digest, dataType, metadataHash, payments);
|
||||
version, service.value, nonce, size, name, identifier, method,
|
||||
secret, compression, data, dataType, metadataHash, payments);
|
||||
|
||||
this.arbitraryTransactionData = transactionData;
|
||||
|
||||
} catch (DataException e) {
|
||||
} catch (DataException | IOException e) {
|
||||
if (arbitraryDataFile != null) {
|
||||
arbitraryDataFile.deleteAll();
|
||||
arbitraryDataFile.deleteAll(true);
|
||||
}
|
||||
throw(e);
|
||||
throw new DataException(e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private boolean isMetadataEqual(ArbitraryDataTransactionMetadata existingMetadata) {
|
||||
if (existingMetadata == null) {
|
||||
return !this.hasMetadata();
|
||||
}
|
||||
if (!Objects.equals(existingMetadata.getTitle(), this.title)) {
|
||||
return false;
|
||||
}
|
||||
@@ -302,6 +323,10 @@ public class ArbitraryDataTransactionBuilder {
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean hasMetadata() {
|
||||
return (this.title != null || this.description != null || this.category != null || this.tags != null);
|
||||
}
|
||||
|
||||
public void computeNonce() throws DataException {
|
||||
if (this.arbitraryTransactionData == null) {
|
||||
throw new DataException("Arbitrary transaction data is required to compute nonce");
|
||||
@@ -313,7 +338,7 @@ public class ArbitraryDataTransactionBuilder {
|
||||
|
||||
Transaction.ValidationResult result = transaction.isValidUnconfirmed();
|
||||
if (result != Transaction.ValidationResult.OK) {
|
||||
arbitraryDataFile.deleteAll();
|
||||
arbitraryDataFile.deleteAll(true);
|
||||
throw new DataException(String.format("Arbitrary transaction invalid: %s", result));
|
||||
}
|
||||
LOGGER.info("Transaction is valid");
|
||||
|
@@ -1,5 +1,7 @@
|
||||
package org.qortal.arbitrary;
|
||||
|
||||
import com.j256.simplemagic.ContentInfo;
|
||||
import com.j256.simplemagic.ContentInfoUtil;
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
@@ -23,16 +25,15 @@ import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.SecretKey;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.net.FileNameMap;
|
||||
import java.net.URLConnection;
|
||||
import java.nio.file.*;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class ArbitraryDataWriter {
|
||||
|
||||
@@ -50,6 +51,8 @@ public class ArbitraryDataWriter {
|
||||
private final String description;
|
||||
private final List<String> tags;
|
||||
private final Category category;
|
||||
private List<String> files;
|
||||
private String mimeType;
|
||||
|
||||
private int chunkSize = ArbitraryDataFile.CHUNK_SIZE;
|
||||
|
||||
@@ -80,12 +83,15 @@ public class ArbitraryDataWriter {
|
||||
this.description = ArbitraryDataTransactionMetadata.limitDescription(description);
|
||||
this.tags = ArbitraryDataTransactionMetadata.limitTags(tags);
|
||||
this.category = category;
|
||||
this.files = new ArrayList<>(); // Populated in buildFileList()
|
||||
this.mimeType = null; // Populated in buildFileList()
|
||||
}
|
||||
|
||||
public void save() throws IOException, DataException, InterruptedException, MissingDataException {
|
||||
try {
|
||||
this.preExecute();
|
||||
this.validateService();
|
||||
this.buildFileList();
|
||||
this.process();
|
||||
this.compress();
|
||||
this.encrypt();
|
||||
@@ -101,10 +107,9 @@ public class ArbitraryDataWriter {
|
||||
private void preExecute() throws DataException {
|
||||
this.checkEnabled();
|
||||
|
||||
// Enforce compression when uploading a directory
|
||||
File file = new File(this.filePath.toString());
|
||||
if (file.isDirectory() && compression == Compression.NONE) {
|
||||
throw new DataException("Unable to upload a directory without compression");
|
||||
// Enforce compression when uploading multiple files
|
||||
if (!FilesystemUtils.isSingleFileResource(this.filePath, false) && compression == Compression.NONE) {
|
||||
throw new DataException("Unable to publish multiple files without compression");
|
||||
}
|
||||
|
||||
// Create temporary working directory
|
||||
@@ -143,6 +148,48 @@ public class ArbitraryDataWriter {
|
||||
}
|
||||
}
|
||||
|
||||
private void buildFileList() throws IOException {
|
||||
// Check if the path already points to a single file
|
||||
boolean isSingleFile = this.filePath.toFile().isFile();
|
||||
Path singleFilePath = null;
|
||||
if (isSingleFile) {
|
||||
this.files.add(this.filePath.getFileName().toString());
|
||||
singleFilePath = this.filePath;
|
||||
}
|
||||
else {
|
||||
// Multi file resources (or a single file in a directory) require a walk through the directory tree
|
||||
try (Stream<Path> stream = Files.walk(this.filePath)) {
|
||||
this.files = stream
|
||||
.filter(Files::isRegularFile)
|
||||
.map(p -> this.filePath.relativize(p).toString())
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (this.files.size() == 1) {
|
||||
singleFilePath = Paths.get(this.filePath.toString(), this.files.get(0));
|
||||
|
||||
// Update filePath to point to the single file (instead of the directory containing the file)
|
||||
this.filePath = singleFilePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (singleFilePath != null) {
|
||||
// Single file resource, so try and determine the MIME type
|
||||
ContentInfoUtil util = new ContentInfoUtil();
|
||||
ContentInfo info = util.findMatch(singleFilePath.toFile());
|
||||
if (info != null) {
|
||||
// Attempt to extract MIME type from file contents
|
||||
this.mimeType = info.getMimeType();
|
||||
}
|
||||
else {
|
||||
// Fall back to using the filename
|
||||
FileNameMap fileNameMap = URLConnection.getFileNameMap();
|
||||
this.mimeType = fileNameMap.getContentTypeFor(singleFilePath.toFile().getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void process() throws DataException, IOException, MissingDataException {
|
||||
switch (this.method) {
|
||||
|
||||
@@ -269,9 +316,6 @@ public class ArbitraryDataWriter {
|
||||
if (chunkCount > 0) {
|
||||
LOGGER.info(String.format("Successfully split into %d chunk%s", chunkCount, (chunkCount == 1 ? "" : "s")));
|
||||
}
|
||||
else {
|
||||
throw new DataException("Unable to split file into chunks");
|
||||
}
|
||||
}
|
||||
|
||||
private void createMetadataFile() throws IOException, DataException {
|
||||
@@ -285,6 +329,8 @@ public class ArbitraryDataWriter {
|
||||
metadata.setTags(this.tags);
|
||||
metadata.setCategory(this.category);
|
||||
metadata.setChunks(this.arbitraryDataFile.chunkHashList());
|
||||
metadata.setFiles(this.files);
|
||||
metadata.setMimeType(this.mimeType);
|
||||
metadata.write();
|
||||
|
||||
// Create an ArbitraryDataFile from the JSON file (we don't have a signature yet)
|
||||
|
@@ -0,0 +1,22 @@
|
||||
package org.qortal.arbitrary.exception;
|
||||
|
||||
import org.qortal.repository.DataException;
|
||||
|
||||
public class DataNotPublishedException extends DataException {
|
||||
|
||||
public DataNotPublishedException() {
|
||||
}
|
||||
|
||||
public DataNotPublishedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public DataNotPublishedException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public DataNotPublishedException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
}
|
@@ -2,12 +2,14 @@ package org.qortal.arbitrary.metadata;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.json.JSONException;
|
||||
import org.qortal.repository.DataException;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
@@ -34,7 +36,7 @@ public class ArbitraryDataMetadata {
|
||||
this.filePath = filePath;
|
||||
}
|
||||
|
||||
protected void readJson() throws DataException {
|
||||
protected void readJson() throws DataException, JSONException {
|
||||
// To be overridden
|
||||
}
|
||||
|
||||
@@ -44,8 +46,13 @@ public class ArbitraryDataMetadata {
|
||||
|
||||
|
||||
public void read() throws IOException, DataException {
|
||||
this.loadJson();
|
||||
this.readJson();
|
||||
try {
|
||||
this.loadJson();
|
||||
this.readJson();
|
||||
|
||||
} catch (JSONException e) {
|
||||
throw new DataException(String.format("Unable to read JSON at path %s: %s", this.filePath, e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
public void write() throws IOException, DataException {
|
||||
@@ -58,6 +65,10 @@ public class ArbitraryDataMetadata {
|
||||
writer.close();
|
||||
}
|
||||
|
||||
public void delete() throws IOException {
|
||||
Files.delete(this.filePath);
|
||||
}
|
||||
|
||||
|
||||
protected void loadJson() throws IOException {
|
||||
File metadataFile = new File(this.filePath.toString());
|
||||
@@ -65,7 +76,7 @@ public class ArbitraryDataMetadata {
|
||||
throw new IOException(String.format("Metadata file doesn't exist: %s", this.filePath.toString()));
|
||||
}
|
||||
|
||||
this.jsonString = new String(Files.readAllBytes(this.filePath));
|
||||
this.jsonString = new String(Files.readAllBytes(this.filePath), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
|
||||
|
@@ -1,5 +1,6 @@
|
||||
package org.qortal.arbitrary.metadata;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.utils.Base58;
|
||||
@@ -22,7 +23,7 @@ public class ArbitraryDataMetadataCache extends ArbitraryDataQortalMetadata {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void readJson() throws DataException {
|
||||
protected void readJson() throws DataException, JSONException {
|
||||
if (this.jsonString == null) {
|
||||
throw new DataException("Patch JSON string is null");
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ package org.qortal.arbitrary.metadata;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import org.qortal.arbitrary.ArbitraryDataDiff.*;
|
||||
import org.qortal.repository.DataException;
|
||||
@@ -40,7 +41,7 @@ public class ArbitraryDataMetadataPatch extends ArbitraryDataQortalMetadata {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void readJson() throws DataException {
|
||||
protected void readJson() throws DataException, JSONException {
|
||||
if (this.jsonString == null) {
|
||||
throw new DataException("Patch JSON string is null");
|
||||
}
|
||||
|
@@ -2,12 +2,14 @@ package org.qortal.arbitrary.metadata;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.json.JSONException;
|
||||
import org.qortal.repository.DataException;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -46,20 +48,6 @@ public class ArbitraryDataQortalMetadata extends ArbitraryDataMetadata {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected void readJson() throws DataException {
|
||||
// To be overridden
|
||||
}
|
||||
|
||||
protected void buildJson() {
|
||||
// To be overridden
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void read() throws IOException, DataException {
|
||||
this.loadJson();
|
||||
this.readJson();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write() throws IOException, DataException {
|
||||
@@ -82,7 +70,7 @@ public class ArbitraryDataQortalMetadata extends ArbitraryDataMetadata {
|
||||
throw new IOException(String.format("Patch file doesn't exist: %s", path.toString()));
|
||||
}
|
||||
|
||||
this.jsonString = new String(Files.readAllBytes(path));
|
||||
this.jsonString = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,9 +82,4 @@ public class ArbitraryDataQortalMetadata extends ArbitraryDataMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public String getJsonString() {
|
||||
return this.jsonString;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,11 +1,13 @@
|
||||
package org.qortal.arbitrary.metadata;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import org.qortal.arbitrary.misc.Category;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
@@ -19,9 +21,11 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata {
|
||||
private String description;
|
||||
private List<String> tags;
|
||||
private Category category;
|
||||
private List<String> files;
|
||||
private String mimeType;
|
||||
|
||||
private static int MAX_TITLE_LENGTH = 80;
|
||||
private static int MAX_DESCRIPTION_LENGTH = 500;
|
||||
private static int MAX_DESCRIPTION_LENGTH = 240;
|
||||
private static int MAX_TAG_LENGTH = 20;
|
||||
private static int MAX_TAGS_COUNT = 5;
|
||||
|
||||
@@ -31,7 +35,7 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void readJson() throws DataException {
|
||||
protected void readJson() throws DataException, JSONException {
|
||||
if (this.jsonString == null) {
|
||||
throw new DataException("Transaction metadata JSON string is null");
|
||||
}
|
||||
@@ -77,6 +81,24 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata {
|
||||
}
|
||||
this.chunks = chunksList;
|
||||
}
|
||||
|
||||
List<String> filesList = new ArrayList<>();
|
||||
if (metadata.has("files")) {
|
||||
JSONArray files = metadata.getJSONArray("files");
|
||||
if (files != null) {
|
||||
for (int i=0; i<files.length(); i++) {
|
||||
String tag = files.getString(i);
|
||||
if (tag != null) {
|
||||
filesList.add(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.files = filesList;
|
||||
}
|
||||
|
||||
if (metadata.has("mimeType")) {
|
||||
this.mimeType = metadata.getString("mimeType");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -111,6 +133,18 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata {
|
||||
}
|
||||
outer.put("chunks", chunks);
|
||||
|
||||
JSONArray files = new JSONArray();
|
||||
if (this.files != null) {
|
||||
for (String file : this.files) {
|
||||
files.put(file);
|
||||
}
|
||||
}
|
||||
outer.put("files", files);
|
||||
|
||||
if (this.mimeType != null && !this.mimeType.isEmpty()) {
|
||||
outer.put("mimeType", this.mimeType);
|
||||
}
|
||||
|
||||
this.jsonString = outer.toString(2);
|
||||
LOGGER.trace("Transaction metadata: {}", this.jsonString);
|
||||
}
|
||||
@@ -156,6 +190,22 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata {
|
||||
return this.category;
|
||||
}
|
||||
|
||||
public void setFiles(List<String> files) {
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
public List<String> getFiles() {
|
||||
return this.files;
|
||||
}
|
||||
|
||||
public void setMimeType(String mimeType) {
|
||||
this.mimeType = mimeType;
|
||||
}
|
||||
|
||||
public String getMimeType() {
|
||||
return this.mimeType;
|
||||
}
|
||||
|
||||
public boolean containsChunk(byte[] chunk) {
|
||||
for (byte[] c : this.chunks) {
|
||||
if (Arrays.equals(c, chunk)) {
|
||||
@@ -168,6 +218,25 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata {
|
||||
|
||||
// Static helper methods
|
||||
|
||||
public static String trimUTF8String(String string, int maxLength) {
|
||||
byte[] inputBytes = string.getBytes(StandardCharsets.UTF_8);
|
||||
int length = Math.min(inputBytes.length, maxLength);
|
||||
byte[] outputBytes = new byte[length];
|
||||
|
||||
System.arraycopy(inputBytes, 0, outputBytes, 0, length);
|
||||
String result = new String(outputBytes, StandardCharsets.UTF_8);
|
||||
|
||||
// check if last character is truncated
|
||||
int lastIndex = result.length() - 1;
|
||||
|
||||
if (lastIndex > 0 && result.charAt(lastIndex) != string.charAt(lastIndex)) {
|
||||
// last character is truncated so remove the last character
|
||||
return result.substring(0, lastIndex);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static String limitTitle(String title) {
|
||||
if (title == null) {
|
||||
return null;
|
||||
@@ -176,7 +245,7 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata {
|
||||
return null;
|
||||
}
|
||||
|
||||
return title.substring(0, Math.min(title.length(), MAX_TITLE_LENGTH));
|
||||
return trimUTF8String(title, MAX_TITLE_LENGTH);
|
||||
}
|
||||
|
||||
public static String limitDescription(String description) {
|
||||
@@ -187,7 +256,7 @@ public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata {
|
||||
return null;
|
||||
}
|
||||
|
||||
return description.substring(0, Math.min(description.length(), MAX_DESCRIPTION_LENGTH));
|
||||
return trimUTF8String(description, MAX_DESCRIPTION_LENGTH);
|
||||
}
|
||||
|
||||
public static List<String> limitTags(List<String> tags) {
|
||||
|
@@ -8,21 +8,59 @@ import org.qortal.utils.FilesystemUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.*;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import static java.util.Arrays.stream;
|
||||
import static java.util.stream.Collectors.toMap;
|
||||
|
||||
public enum Service {
|
||||
AUTO_UPDATE(1, false, null, null),
|
||||
ARBITRARY_DATA(100, false, null, null),
|
||||
WEBSITE(200, true, null, null) {
|
||||
AUTO_UPDATE(1, false, null, false, false, null),
|
||||
ARBITRARY_DATA(100, false, null, false, false, null),
|
||||
QCHAT_ATTACHMENT(120, true, 1024*1024L, true, false, null) {
|
||||
@Override
|
||||
public ValidationResult validate(Path path) {
|
||||
public ValidationResult validate(Path path) throws IOException {
|
||||
ValidationResult superclassResult = super.validate(path);
|
||||
if (superclassResult != ValidationResult.OK) {
|
||||
return superclassResult;
|
||||
}
|
||||
|
||||
File[] files = path.toFile().listFiles();
|
||||
// If already a single file, replace the list with one that contains that file only
|
||||
if (files == null && path.toFile().isFile()) {
|
||||
files = new File[] { path.toFile() };
|
||||
}
|
||||
// Now validate the file's extension
|
||||
if (files != null && files[0] != null) {
|
||||
final String extension = FilenameUtils.getExtension(files[0].getName()).toLowerCase();
|
||||
// We must allow blank file extensions because these are used by data published from a plaintext or base64-encoded string
|
||||
final List<String> allowedExtensions = Arrays.asList("zip", "pdf", "txt", "odt", "ods", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "");
|
||||
if (extension == null || !allowedExtensions.contains(extension)) {
|
||||
return ValidationResult.INVALID_FILE_EXTENSION;
|
||||
}
|
||||
}
|
||||
return ValidationResult.OK;
|
||||
}
|
||||
},
|
||||
QCHAT_ATTACHMENT_PRIVATE(121, true, 1024*1024L, true, true, null),
|
||||
ATTACHMENT(130, false, 50*1024*1024L, true, false, null),
|
||||
ATTACHMENT_PRIVATE(131, true, 50*1024*1024L, true, true, null),
|
||||
FILE(140, false, null, true, false, null),
|
||||
FILE_PRIVATE(141, true, null, true, true, null),
|
||||
FILES(150, false, null, false, false, null),
|
||||
CHAIN_DATA(160, true, 239L, true, false, null),
|
||||
WEBSITE(200, true, null, false, false, null) {
|
||||
@Override
|
||||
public ValidationResult validate(Path path) throws IOException {
|
||||
ValidationResult superclassResult = super.validate(path);
|
||||
if (superclassResult != ValidationResult.OK) {
|
||||
return superclassResult;
|
||||
}
|
||||
|
||||
// Custom validation function to require an index HTML file in the root directory
|
||||
List<String> fileNames = ArbitraryDataRenderer.indexFiles();
|
||||
String[] files = path.toFile().list();
|
||||
@@ -37,28 +75,68 @@ public enum Service {
|
||||
return ValidationResult.MISSING_INDEX_FILE;
|
||||
}
|
||||
},
|
||||
GIT_REPOSITORY(300, false, null, null),
|
||||
IMAGE(400, true, 10*1024*1024L, null),
|
||||
THUMBNAIL(410, true, 500*1024L, null),
|
||||
QCHAT_IMAGE(420, true, 500*1024L, null),
|
||||
VIDEO(500, false, null, null),
|
||||
AUDIO(600, false, null, null),
|
||||
BLOG(700, false, null, null),
|
||||
BLOG_POST(777, false, null, null),
|
||||
BLOG_COMMENT(778, false, null, null),
|
||||
DOCUMENT(800, false, null, null),
|
||||
LIST(900, true, null, null),
|
||||
PLAYLIST(910, true, null, null),
|
||||
APP(1000, false, null, null),
|
||||
METADATA(1100, false, null, null),
|
||||
GIF_REPOSITORY(1200, true, 25*1024*1024L, null) {
|
||||
GIT_REPOSITORY(300, false, null, false, false, null),
|
||||
IMAGE(400, true, 10*1024*1024L, true, false, null),
|
||||
IMAGE_PRIVATE(401, true, 10*1024*1024L, true, true, null),
|
||||
THUMBNAIL(410, true, 500*1024L, true, false, null),
|
||||
QCHAT_IMAGE(420, true, 500*1024L, true, false, null),
|
||||
VIDEO(500, false, null, true, false, null),
|
||||
VIDEO_PRIVATE(501, true, null, true, true, null),
|
||||
AUDIO(600, false, null, true, false, null),
|
||||
AUDIO_PRIVATE(601, true, null, true, true, null),
|
||||
QCHAT_AUDIO(610, true, 10*1024*1024L, true, false, null),
|
||||
QCHAT_VOICE(620, true, 10*1024*1024L, true, false, null),
|
||||
VOICE(630, true, 10*1024*1024L, true, false, null),
|
||||
VOICE_PRIVATE(631, true, 10*1024*1024L, true, true, null),
|
||||
PODCAST(640, false, null, true, false, null),
|
||||
BLOG(700, false, null, false, false, null),
|
||||
BLOG_POST(777, false, null, true, false, null),
|
||||
BLOG_COMMENT(778, true, 500*1024L, true, false, null),
|
||||
DOCUMENT(800, false, null, true, false, null),
|
||||
DOCUMENT_PRIVATE(801, true, null, true, true, null),
|
||||
LIST(900, true, null, true, false, null),
|
||||
PLAYLIST(910, true, null, true, false, null),
|
||||
APP(1000, true, 50*1024*1024L, false, false, null),
|
||||
METADATA(1100, false, null, true, false, null),
|
||||
JSON(1110, true, 25*1024L, true, false, null) {
|
||||
@Override
|
||||
public ValidationResult validate(Path path) {
|
||||
public ValidationResult validate(Path path) throws IOException {
|
||||
ValidationResult superclassResult = super.validate(path);
|
||||
if (superclassResult != ValidationResult.OK) {
|
||||
return superclassResult;
|
||||
}
|
||||
|
||||
// Require valid JSON
|
||||
byte[] data = FilesystemUtils.getSingleFileContents(path, 25*1024);
|
||||
String json = new String(data, StandardCharsets.UTF_8);
|
||||
try {
|
||||
objectMapper.readTree(json);
|
||||
return ValidationResult.OK;
|
||||
} catch (IOException e) {
|
||||
return ValidationResult.INVALID_CONTENT;
|
||||
}
|
||||
}
|
||||
},
|
||||
GIF_REPOSITORY(1200, true, 25*1024*1024L, false, false, null) {
|
||||
@Override
|
||||
public ValidationResult validate(Path path) throws IOException {
|
||||
ValidationResult superclassResult = super.validate(path);
|
||||
if (superclassResult != ValidationResult.OK) {
|
||||
return superclassResult;
|
||||
}
|
||||
|
||||
// Custom validation function to require .gif files only, and at least 1
|
||||
int gifCount = 0;
|
||||
File[] files = path.toFile().listFiles();
|
||||
// If already a single file, replace the list with one that contains that file only
|
||||
if (files == null && path.toFile().isFile()) {
|
||||
files = new File[] { path.toFile() };
|
||||
}
|
||||
if (files != null) {
|
||||
for (File file : files) {
|
||||
if (file.getName().equals(".qortal")) {
|
||||
continue;
|
||||
}
|
||||
if (file.isDirectory()) {
|
||||
return ValidationResult.DIRECTORIES_NOT_ALLOWED;
|
||||
}
|
||||
@@ -74,20 +152,47 @@ public enum Service {
|
||||
}
|
||||
return ValidationResult.OK;
|
||||
}
|
||||
};
|
||||
},
|
||||
STORE(1300, false, null, true, false, null),
|
||||
PRODUCT(1310, false, null, true, false, null),
|
||||
OFFER(1330, false, null, true, false, null),
|
||||
COUPON(1340, false, null, true, false, null),
|
||||
CODE(1400, false, null, true, false, null),
|
||||
PLUGIN(1410, false, null, true, false, null),
|
||||
EXTENSION(1420, false, null, true, false, null),
|
||||
GAME(1500, false, null, false, false, null),
|
||||
ITEM(1510, false, null, true, false, null),
|
||||
NFT(1600, false, null, true, false, null),
|
||||
DATABASE(1700, false, null, false, false, null),
|
||||
SNAPSHOT(1710, false, null, false, false, null),
|
||||
COMMENT(1800, true, 500*1024L, true, false, null),
|
||||
CHAIN_COMMENT(1810, true, 239L, true, false, null),
|
||||
MAIL(1900, true, 1024*1024L, true, false, null),
|
||||
MAIL_PRIVATE(1901, true, 1024*1024L, true, true, null),
|
||||
MESSAGE(1910, true, 1024*1024L, true, false, null),
|
||||
MESSAGE_PRIVATE(1911, true, 1024*1024L, true, true, null);
|
||||
|
||||
public final int value;
|
||||
private final boolean requiresValidation;
|
||||
private final Long maxSize;
|
||||
private final boolean single;
|
||||
private final boolean isPrivate;
|
||||
private final List<String> requiredKeys;
|
||||
|
||||
private static final Map<Integer, Service> map = stream(Service.values())
|
||||
.collect(toMap(service -> service.value, service -> service));
|
||||
|
||||
Service(int value, boolean requiresValidation, Long maxSize, List<String> requiredKeys) {
|
||||
// For JSON validation
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private static final String encryptedDataPrefix = "qortalEncryptedData";
|
||||
|
||||
Service(int value, boolean requiresValidation, Long maxSize, boolean single, boolean isPrivate, List<String> requiredKeys) {
|
||||
this.value = value;
|
||||
this.requiresValidation = requiresValidation;
|
||||
this.maxSize = maxSize;
|
||||
this.single = single;
|
||||
this.isPrivate = isPrivate;
|
||||
this.requiredKeys = requiredKeys;
|
||||
}
|
||||
|
||||
@@ -96,7 +201,9 @@ public enum Service {
|
||||
return ValidationResult.OK;
|
||||
}
|
||||
|
||||
byte[] data = FilesystemUtils.getSingleFileContents(path);
|
||||
// Load the first 25KB of data. This only needs to be long enough to check the prefix
|
||||
// and also to allow for possible additional future validation of smaller files.
|
||||
byte[] data = FilesystemUtils.getSingleFileContents(path, 25*1024);
|
||||
long size = FilesystemUtils.getDirectorySize(path);
|
||||
|
||||
// Validate max size if needed
|
||||
@@ -106,6 +213,22 @@ public enum Service {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate file count if needed
|
||||
if (this.single && data == null) {
|
||||
return ValidationResult.INVALID_FILE_COUNT;
|
||||
}
|
||||
|
||||
// Validate private data for single file resources
|
||||
if (this.single) {
|
||||
String dataString = new String(data, StandardCharsets.UTF_8);
|
||||
if (this.isPrivate && !dataString.startsWith(encryptedDataPrefix)) {
|
||||
return ValidationResult.DATA_NOT_ENCRYPTED;
|
||||
}
|
||||
if (!this.isPrivate && dataString.startsWith(encryptedDataPrefix)) {
|
||||
return ValidationResult.DATA_ENCRYPTED;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required keys if needed
|
||||
if (this.requiredKeys != null) {
|
||||
if (data == null) {
|
||||
@@ -124,7 +247,12 @@ public enum Service {
|
||||
}
|
||||
|
||||
public boolean isValidationRequired() {
|
||||
return this.requiresValidation;
|
||||
// We must always validate single file resources, to ensure they are actually a single file
|
||||
return this.requiresValidation || this.single;
|
||||
}
|
||||
|
||||
public boolean isPrivate() {
|
||||
return this.isPrivate;
|
||||
}
|
||||
|
||||
public static Service valueOf(int value) {
|
||||
@@ -132,10 +260,41 @@ public enum Service {
|
||||
}
|
||||
|
||||
public static JSONObject toJsonObject(byte[] data) {
|
||||
String dataString = new String(data);
|
||||
String dataString = new String(data, StandardCharsets.UTF_8);
|
||||
return new JSONObject(dataString);
|
||||
}
|
||||
|
||||
public static List<Service> publicServices() {
|
||||
List<Service> privateServices = new ArrayList<>();
|
||||
for (Service service : Service.values()) {
|
||||
if (!service.isPrivate) {
|
||||
privateServices.add(service);
|
||||
}
|
||||
}
|
||||
return privateServices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a list of Service objects that require encrypted data.
|
||||
*
|
||||
* These can ultimately be used to help inform the cleanup manager
|
||||
* on the best order to delete files when the node runs out of space.
|
||||
* Public data should be given priority over private data (unless
|
||||
* this node is part of a data market contract for that data - this
|
||||
* isn't developed yet).
|
||||
*
|
||||
* @return a list of Service objects that require encrypted data.
|
||||
*/
|
||||
public static List<Service> privateServices() {
|
||||
List<Service> privateServices = new ArrayList<>();
|
||||
for (Service service : Service.values()) {
|
||||
if (service.isPrivate) {
|
||||
privateServices.add(service);
|
||||
}
|
||||
}
|
||||
return privateServices;
|
||||
}
|
||||
|
||||
public enum ValidationResult {
|
||||
OK(1),
|
||||
MISSING_KEYS(2),
|
||||
@@ -143,7 +302,11 @@ public enum Service {
|
||||
MISSING_INDEX_FILE(4),
|
||||
DIRECTORIES_NOT_ALLOWED(5),
|
||||
INVALID_FILE_EXTENSION(6),
|
||||
MISSING_DATA(7);
|
||||
MISSING_DATA(7),
|
||||
INVALID_FILE_COUNT(8),
|
||||
INVALID_CONTENT(9),
|
||||
DATA_NOT_ENCRYPTED(10),
|
||||
DATA_ENCRYPTED(10);
|
||||
|
||||
public final int value;
|
||||
|
||||
|
@@ -657,6 +657,10 @@ public class Block {
|
||||
return this.atStates;
|
||||
}
|
||||
|
||||
public byte[] getAtStatesHash() {
|
||||
return this.atStatesHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return expanded info on block's online accounts.
|
||||
* <p>
|
||||
|
@@ -76,7 +76,10 @@ public class BlockChain {
|
||||
disableReferenceTimestamp,
|
||||
increaseOnlineAccountsDifficultyTimestamp,
|
||||
onlineAccountMinterLevelValidationHeight,
|
||||
selfSponsorshipAlgoV1Height;
|
||||
selfSponsorshipAlgoV1Height,
|
||||
feeValidationFixTimestamp,
|
||||
chatReferenceTimestamp,
|
||||
arbitraryOptionalFeeTimestamp;
|
||||
}
|
||||
|
||||
// Custom transaction fees
|
||||
@@ -98,6 +101,13 @@ public class BlockChain {
|
||||
/** Whether only one registered name is allowed per account. */
|
||||
private boolean oneNamePerAccount = false;
|
||||
|
||||
/** Checkpoints */
|
||||
public static class Checkpoint {
|
||||
public int height;
|
||||
public String signature;
|
||||
}
|
||||
private List<Checkpoint> checkpoints;
|
||||
|
||||
/** Block rewards by block height */
|
||||
public static class RewardByHeight {
|
||||
public int height;
|
||||
@@ -379,6 +389,10 @@ public class BlockChain {
|
||||
return this.oneNamePerAccount;
|
||||
}
|
||||
|
||||
public List<Checkpoint> getCheckpoints() {
|
||||
return this.checkpoints;
|
||||
}
|
||||
|
||||
public List<RewardByHeight> getBlockRewardsByHeight() {
|
||||
return this.rewardsByHeight;
|
||||
}
|
||||
@@ -501,6 +515,18 @@ public class BlockChain {
|
||||
return this.featureTriggers.get(FeatureTrigger.onlineAccountMinterLevelValidationHeight.name()).intValue();
|
||||
}
|
||||
|
||||
public long getFeeValidationFixTimestamp() {
|
||||
return this.featureTriggers.get(FeatureTrigger.feeValidationFixTimestamp.name()).longValue();
|
||||
}
|
||||
|
||||
public long getChatReferenceTimestamp() {
|
||||
return this.featureTriggers.get(FeatureTrigger.chatReferenceTimestamp.name()).longValue();
|
||||
}
|
||||
|
||||
public long getArbitraryOptionalFeeTimestamp() {
|
||||
return this.featureTriggers.get(FeatureTrigger.arbitraryOptionalFeeTimestamp.name()).longValue();
|
||||
}
|
||||
|
||||
|
||||
// More complex getters for aspects that change by height or timestamp
|
||||
|
||||
@@ -669,6 +695,7 @@ public class BlockChain {
|
||||
|
||||
boolean isTopOnly = Settings.getInstance().isTopOnly();
|
||||
boolean archiveEnabled = Settings.getInstance().isArchiveEnabled();
|
||||
boolean isLite = Settings.getInstance().isLite();
|
||||
boolean canBootstrap = Settings.getInstance().getBootstrap();
|
||||
boolean needsArchiveRebuild = false;
|
||||
BlockData chainTip;
|
||||
@@ -689,22 +716,44 @@ public class BlockChain {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checkpoints
|
||||
// Limited to topOnly nodes for now, in order to reduce risk, and to solve a real-world problem with divergent topOnly nodes
|
||||
// TODO: remove the isTopOnly conditional below once this feature has had more testing time
|
||||
if (isTopOnly && !isLite) {
|
||||
List<Checkpoint> checkpoints = BlockChain.getInstance().getCheckpoints();
|
||||
for (Checkpoint checkpoint : checkpoints) {
|
||||
BlockData blockData = repository.getBlockRepository().fromHeight(checkpoint.height);
|
||||
if (blockData == null) {
|
||||
// Try the archive
|
||||
blockData = repository.getBlockArchiveRepository().fromHeight(checkpoint.height);
|
||||
}
|
||||
if (blockData == null) {
|
||||
LOGGER.trace("Couldn't find block for height {}", checkpoint.height);
|
||||
// This is likely due to the block being pruned, so is safe to ignore.
|
||||
// Continue, as there might be other blocks we can check more definitively.
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] signature = Base58.decode(checkpoint.signature);
|
||||
if (!Arrays.equals(signature, blockData.getSignature())) {
|
||||
LOGGER.info("Error: block at height {} with signature {} doesn't match checkpoint sig: {}. Bootstrapping...", checkpoint.height, Base58.encode(blockData.getSignature()), checkpoint.signature);
|
||||
needsArchiveRebuild = true;
|
||||
break;
|
||||
}
|
||||
LOGGER.info("Block at height {} matches checkpoint signature", blockData.getHeight());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1);
|
||||
// Check first block is Genesis Block
|
||||
if (!isGenesisBlockValid() || needsArchiveRebuild) {
|
||||
try {
|
||||
rebuildBlockchain();
|
||||
|
||||
if (isTopOnly && hasBlocks) {
|
||||
// Top-only mode is enabled and we have blocks, so it's possible that the genesis block has been pruned
|
||||
// It's best not to validate it, and there's no real need to
|
||||
} else {
|
||||
// Check first block is Genesis Block
|
||||
if (!isGenesisBlockValid() || needsArchiveRebuild) {
|
||||
try {
|
||||
rebuildBlockchain();
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
throw new DataException(String.format("Interrupted when trying to rebuild blockchain: %s", e.getMessage()));
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
throw new DataException(String.format("Interrupted when trying to rebuild blockchain: %s", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,9 +762,7 @@ public class BlockChain {
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
repository.checkConsistency();
|
||||
|
||||
// Set the number of blocks to validate based on the pruned state of the chain
|
||||
// If pruned, subtract an extra 10 to allow room for error
|
||||
int blocksToValidate = (isTopOnly || archiveEnabled) ? Settings.getInstance().getPruneBlockLimit() - 10 : 1440;
|
||||
int blocksToValidate = Math.min(Settings.getInstance().getPruneBlockLimit() - 10, 1440);
|
||||
|
||||
int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - blocksToValidate, 1);
|
||||
BlockData detachedBlockData = repository.getBlockRepository().getDetachedBlockSignature(startHeight);
|
||||
|
@@ -293,4 +293,77 @@ public class AutoUpdate extends Thread {
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean attemptRestart() {
|
||||
LOGGER.info(String.format("Restarting node..."));
|
||||
|
||||
// Give repository a chance to backup in case things go badly wrong (if enabled)
|
||||
if (Settings.getInstance().getRepositoryBackupInterval() > 0) {
|
||||
try {
|
||||
// Timeout if the database isn't ready for backing up after 60 seconds
|
||||
long timeout = 60 * 1000L;
|
||||
RepositoryManager.backup(true, "backup", timeout);
|
||||
|
||||
} catch (TimeoutException e) {
|
||||
LOGGER.info("Attempt to backup repository failed due to timeout: {}", e.getMessage());
|
||||
// Continue with the node restart anyway...
|
||||
}
|
||||
}
|
||||
|
||||
// Call ApplyUpdate to end this process (unlocking current JAR so it can be replaced)
|
||||
String javaHome = System.getProperty("java.home");
|
||||
LOGGER.debug(String.format("Java home: %s", javaHome));
|
||||
|
||||
Path javaBinary = Paths.get(javaHome, "bin", "java");
|
||||
LOGGER.debug(String.format("Java binary: %s", javaBinary));
|
||||
|
||||
try {
|
||||
List<String> javaCmd = new ArrayList<>();
|
||||
// Java runtime binary itself
|
||||
javaCmd.add(javaBinary.toString());
|
||||
|
||||
// JVM arguments
|
||||
javaCmd.addAll(ManagementFactory.getRuntimeMXBean().getInputArguments());
|
||||
|
||||
// Disable, but retain, any -agentlib JVM arg as sub-process might fail if it tries to reuse same port
|
||||
javaCmd = javaCmd.stream()
|
||||
.map(arg -> arg.replace("-agentlib", AGENTLIB_JVM_HOLDER_ARG))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Remove JNI options as they won't be supported by command-line 'java'
|
||||
// These are typically added by the AdvancedInstaller Java launcher EXE
|
||||
javaCmd.removeAll(Arrays.asList("abort", "exit", "vfprintf"));
|
||||
|
||||
// Call ApplyUpdate using JAR
|
||||
javaCmd.addAll(Arrays.asList("-cp", JAR_FILENAME, ApplyUpdate.class.getCanonicalName()));
|
||||
|
||||
// Add command-line args saved from start-up
|
||||
String[] savedArgs = Controller.getInstance().getSavedArgs();
|
||||
if (savedArgs != null)
|
||||
javaCmd.addAll(Arrays.asList(savedArgs));
|
||||
|
||||
LOGGER.info(String.format("Restarting node with: %s", String.join(" ", javaCmd)));
|
||||
|
||||
SysTray.getInstance().showMessage(Translator.INSTANCE.translate("SysTray", "AUTO_UPDATE"), //TODO
|
||||
Translator.INSTANCE.translate("SysTray", "APPLYING_UPDATE_AND_RESTARTING"), //TODO
|
||||
MessageType.INFO);
|
||||
|
||||
ProcessBuilder processBuilder = new ProcessBuilder(javaCmd);
|
||||
|
||||
// New process will inherit our stdout and stderr
|
||||
processBuilder.redirectOutput(ProcessBuilder.Redirect.INHERIT);
|
||||
processBuilder.redirectError(ProcessBuilder.Redirect.INHERIT);
|
||||
|
||||
Process process = processBuilder.start();
|
||||
|
||||
// Nothing to pipe to new process, so close output stream (process's stdin)
|
||||
process.getOutputStream().close();
|
||||
|
||||
return true; // restarting node OK
|
||||
} catch (Exception e) {
|
||||
LOGGER.error(String.format("Failed to restart node: %s", e.getMessage()));
|
||||
|
||||
return true; // repo was okay, even if applying update failed
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -63,8 +63,8 @@ public class BlockMinter extends Thread {
|
||||
public void run() {
|
||||
Thread.currentThread().setName("BlockMinter");
|
||||
|
||||
if (Settings.getInstance().isLite()) {
|
||||
// Lite nodes do not mint
|
||||
if (Settings.getInstance().isTopOnly() || Settings.getInstance().isLite()) {
|
||||
// Top only and lite nodes do not sign blocks
|
||||
return;
|
||||
}
|
||||
if (Settings.getInstance().getWipeUnconfirmedOnStart()) {
|
||||
@@ -432,6 +432,10 @@ public class BlockMinter extends Thread {
|
||||
// Unable to process block - report and discard
|
||||
LOGGER.error("Unable to process newly minted block?", e);
|
||||
newBlocks.clear();
|
||||
} catch (ArithmeticException e) {
|
||||
// Unable to process block - report and discard
|
||||
LOGGER.error("Unable to process newly minted block?", e);
|
||||
newBlocks.clear();
|
||||
}
|
||||
} finally {
|
||||
blockchainLock.unlock();
|
||||
|
@@ -400,12 +400,8 @@ public class Controller extends Thread {
|
||||
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(getRepositoryUrl());
|
||||
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
||||
RepositoryManager.setRequestedCheckpoint(Boolean.TRUE);
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
RepositoryManager.archive(repository);
|
||||
RepositoryManager.prune(repository);
|
||||
}
|
||||
} catch (DataException e) {
|
||||
}
|
||||
catch (DataException e) {
|
||||
// If exception has no cause then repository is in use by some other process.
|
||||
if (e.getCause() == null) {
|
||||
LOGGER.info("Repository in use by another process?");
|
||||
@@ -769,6 +765,16 @@ public class Controller extends Thread {
|
||||
}
|
||||
};
|
||||
|
||||
public static final Predicate<Peer> wasRecentlyTooDivergent = peer -> {
|
||||
Long now = NTP.getTime();
|
||||
Long peerLastTooDivergentTime = peer.getLastTooDivergentTime();
|
||||
if (now == null || peerLastTooDivergentTime == null)
|
||||
return false;
|
||||
|
||||
// Exclude any peers that were TOO_DIVERGENT in the last 5 mins
|
||||
return (now - peerLastTooDivergentTime < 5 * 60 * 1000L);
|
||||
};
|
||||
|
||||
private long getRandomRepositoryMaintenanceInterval() {
|
||||
final long minInterval = Settings.getInstance().getRepositoryMaintenanceMinInterval();
|
||||
final long maxInterval = Settings.getInstance().getRepositoryMaintenanceMaxInterval();
|
||||
@@ -1369,9 +1375,24 @@ public class Controller extends Thread {
|
||||
// If we have no block data, we should check the archive in case it's there
|
||||
if (blockData == null) {
|
||||
if (Settings.getInstance().isArchiveEnabled()) {
|
||||
byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, true, repository);
|
||||
if (bytes != null) {
|
||||
CachedBlockMessage blockMessage = new CachedBlockMessage(bytes);
|
||||
Triple<byte[], Integer, Integer> serializedBlock = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, true, repository);
|
||||
if (serializedBlock != null) {
|
||||
byte[] bytes = serializedBlock.getA();
|
||||
Integer serializationVersion = serializedBlock.getB();
|
||||
|
||||
Message blockMessage;
|
||||
switch (serializationVersion) {
|
||||
case 1:
|
||||
blockMessage = new CachedBlockMessage(bytes);
|
||||
break;
|
||||
|
||||
case 2:
|
||||
blockMessage = new CachedBlockV2Message(bytes);
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
blockMessage.setId(message.getId());
|
||||
|
||||
// This call also causes the other needed data to be pulled in from repository
|
||||
|
@@ -504,110 +504,118 @@ public class OnlineAccountsManager {
|
||||
computeOurAccountsForTimestamp(onlineAccountsTimestamp);
|
||||
}
|
||||
|
||||
private boolean computeOurAccountsForTimestamp(long onlineAccountsTimestamp) {
|
||||
List<MintingAccountData> mintingAccounts;
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
mintingAccounts = repository.getAccountRepository().getMintingAccounts();
|
||||
private boolean computeOurAccountsForTimestamp(Long onlineAccountsTimestamp) {
|
||||
if (onlineAccountsTimestamp != null) {
|
||||
List<MintingAccountData> mintingAccounts;
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
mintingAccounts = repository.getAccountRepository().getMintingAccounts();
|
||||
|
||||
// We have no accounts to send
|
||||
if (mintingAccounts.isEmpty())
|
||||
// We have no accounts to send
|
||||
if (mintingAccounts.isEmpty())
|
||||
return false;
|
||||
|
||||
// Only active reward-shares allowed
|
||||
Iterator<MintingAccountData> iterator = mintingAccounts.iterator();
|
||||
int i = 0;
|
||||
while (iterator.hasNext()) {
|
||||
MintingAccountData mintingAccountData = iterator.next();
|
||||
|
||||
RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(mintingAccountData.getPublicKey());
|
||||
if (rewardShareData == null) {
|
||||
// Reward-share doesn't even exist - probably not a good sign
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
Account mintingAccount = new Account(repository, rewardShareData.getMinter());
|
||||
if (!mintingAccount.canMint()) {
|
||||
// Minting-account component of reward-share can no longer mint - disregard
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (++i > 1 + 1) {
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.warn(String.format("Repository issue trying to fetch minting accounts: %s", e.getMessage()));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only active reward-shares allowed
|
||||
Iterator<MintingAccountData> iterator = mintingAccounts.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
MintingAccountData mintingAccountData = iterator.next();
|
||||
byte[] timestampBytes = Longs.toByteArray(onlineAccountsTimestamp);
|
||||
List<OnlineAccountData> ourOnlineAccounts = new ArrayList<>();
|
||||
|
||||
RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(mintingAccountData.getPublicKey());
|
||||
if (rewardShareData == null) {
|
||||
// Reward-share doesn't even exist - probably not a good sign
|
||||
iterator.remove();
|
||||
int remaining = mintingAccounts.size();
|
||||
for (MintingAccountData mintingAccountData : mintingAccounts) {
|
||||
remaining--;
|
||||
byte[] privateKey = mintingAccountData.getPrivateKey();
|
||||
byte[] publicKey = Crypto.toPublicKey(privateKey);
|
||||
|
||||
// We don't want to compute the online account nonce and signature again if it already exists
|
||||
Set<OnlineAccountData> onlineAccounts = this.currentOnlineAccounts.computeIfAbsent(onlineAccountsTimestamp, k -> ConcurrentHashMap.newKeySet());
|
||||
boolean alreadyExists = onlineAccounts.stream().anyMatch(a -> Arrays.equals(a.getPublicKey(), publicKey));
|
||||
if (alreadyExists) {
|
||||
this.hasOurOnlineAccounts = true;
|
||||
|
||||
if (remaining > 0) {
|
||||
// Move on to next account
|
||||
continue;
|
||||
} else {
|
||||
// Everything exists, so return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate bytes for mempow
|
||||
byte[] mempowBytes;
|
||||
try {
|
||||
mempowBytes = this.getMemoryPoWBytes(publicKey, onlineAccountsTimestamp);
|
||||
} catch (IOException e) {
|
||||
LOGGER.info("Unable to create bytes for MemoryPoW. Moving on to next account...");
|
||||
continue;
|
||||
}
|
||||
|
||||
Account mintingAccount = new Account(repository, rewardShareData.getMinter());
|
||||
if (!mintingAccount.canMint()) {
|
||||
// Minting-account component of reward-share can no longer mint - disregard
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (DataException e) {
|
||||
LOGGER.warn(String.format("Repository issue trying to fetch minting accounts: %s", e.getMessage()));
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] timestampBytes = Longs.toByteArray(onlineAccountsTimestamp);
|
||||
List<OnlineAccountData> ourOnlineAccounts = new ArrayList<>();
|
||||
|
||||
int remaining = mintingAccounts.size();
|
||||
for (MintingAccountData mintingAccountData : mintingAccounts) {
|
||||
remaining--;
|
||||
byte[] privateKey = mintingAccountData.getPrivateKey();
|
||||
byte[] publicKey = Crypto.toPublicKey(privateKey);
|
||||
|
||||
// We don't want to compute the online account nonce and signature again if it already exists
|
||||
Set<OnlineAccountData> onlineAccounts = this.currentOnlineAccounts.computeIfAbsent(onlineAccountsTimestamp, k -> ConcurrentHashMap.newKeySet());
|
||||
boolean alreadyExists = onlineAccounts.stream().anyMatch(a -> Arrays.equals(a.getPublicKey(), publicKey));
|
||||
if (alreadyExists) {
|
||||
this.hasOurOnlineAccounts = true;
|
||||
|
||||
if (remaining > 0) {
|
||||
// Move on to next account
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
// Everything exists, so return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate bytes for mempow
|
||||
byte[] mempowBytes;
|
||||
try {
|
||||
mempowBytes = this.getMemoryPoWBytes(publicKey, onlineAccountsTimestamp);
|
||||
}
|
||||
catch (IOException e) {
|
||||
LOGGER.info("Unable to create bytes for MemoryPoW. Moving on to next account...");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute nonce
|
||||
Integer nonce;
|
||||
try {
|
||||
nonce = this.computeMemoryPoW(mempowBytes, publicKey, onlineAccountsTimestamp);
|
||||
if (nonce == null) {
|
||||
// A nonce is required
|
||||
// Compute nonce
|
||||
Integer nonce;
|
||||
try {
|
||||
nonce = this.computeMemoryPoW(mempowBytes, publicKey, onlineAccountsTimestamp);
|
||||
if (nonce == null) {
|
||||
// A nonce is required
|
||||
return false;
|
||||
}
|
||||
} catch (TimeoutException e) {
|
||||
LOGGER.info(String.format("Timed out computing nonce for account %.8s", Base58.encode(publicKey)));
|
||||
return false;
|
||||
}
|
||||
} catch (TimeoutException e) {
|
||||
LOGGER.info(String.format("Timed out computing nonce for account %.8s", Base58.encode(publicKey)));
|
||||
|
||||
byte[] signature = Qortal25519Extras.signForAggregation(privateKey, timestampBytes);
|
||||
|
||||
// Our account is online
|
||||
OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce);
|
||||
|
||||
// Make sure to verify before adding
|
||||
if (verifyMemoryPoW(ourOnlineAccountData, null)) {
|
||||
ourOnlineAccounts.add(ourOnlineAccountData);
|
||||
}
|
||||
}
|
||||
|
||||
this.hasOurOnlineAccounts = !ourOnlineAccounts.isEmpty();
|
||||
|
||||
boolean hasInfoChanged = addAccounts(ourOnlineAccounts);
|
||||
|
||||
if (!hasInfoChanged)
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] signature = Qortal25519Extras.signForAggregation(privateKey, timestampBytes);
|
||||
Network.getInstance().broadcast(peer -> new OnlineAccountsV3Message(ourOnlineAccounts));
|
||||
|
||||
// Our account is online
|
||||
OnlineAccountData ourOnlineAccountData = new OnlineAccountData(onlineAccountsTimestamp, signature, publicKey, nonce);
|
||||
LOGGER.debug("Broadcasted {} online account{} with timestamp {}", ourOnlineAccounts.size(), (ourOnlineAccounts.size() != 1 ? "s" : ""), onlineAccountsTimestamp);
|
||||
|
||||
// Make sure to verify before adding
|
||||
if (verifyMemoryPoW(ourOnlineAccountData, null)) {
|
||||
ourOnlineAccounts.add(ourOnlineAccountData);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
this.hasOurOnlineAccounts = !ourOnlineAccounts.isEmpty();
|
||||
|
||||
boolean hasInfoChanged = addAccounts(ourOnlineAccounts);
|
||||
|
||||
if (!hasInfoChanged)
|
||||
return false;
|
||||
|
||||
Network.getInstance().broadcast(peer -> new OnlineAccountsV3Message(ourOnlineAccounts));
|
||||
|
||||
LOGGER.debug("Broadcasted {} online account{} with timestamp {}", ourOnlineAccounts.size(), (ourOnlineAccounts.size() != 1 ? "s" : ""), onlineAccountsTimestamp);
|
||||
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
@@ -163,7 +163,7 @@ public class PirateChainWalletController extends Thread {
|
||||
|
||||
// Library not found, so check if we've fetched the resource from QDN
|
||||
ArbitraryTransactionData t = this.getTransactionData(repository);
|
||||
if (t == null) {
|
||||
if (t == null || t.getService() == null) {
|
||||
// Can't find the transaction - maybe on a different chain?
|
||||
return;
|
||||
}
|
||||
|
@@ -1121,6 +1121,7 @@ public class Synchronizer extends Thread {
|
||||
// If common block is too far behind us then we're on massively different forks so give up.
|
||||
if (!force && testHeight < ourHeight - MAXIMUM_COMMON_DELTA) {
|
||||
LOGGER.info(String.format("Blockchain too divergent with peer %s", peer));
|
||||
peer.setLastTooDivergentTime(NTP.getTime());
|
||||
return SynchronizationResult.TOO_DIVERGENT;
|
||||
}
|
||||
|
||||
@@ -1130,6 +1131,9 @@ public class Synchronizer extends Thread {
|
||||
testHeight = Math.max(testHeight - step, 1);
|
||||
}
|
||||
|
||||
// Peer not considered too divergent
|
||||
peer.setLastTooDivergentTime(0L);
|
||||
|
||||
// Prepend test block's summary as first block summary, as summaries returned are *after* test block
|
||||
BlockSummaryData testBlockSummary = new BlockSummaryData(testBlockData);
|
||||
blockSummariesFromCommon.add(0, testBlockSummary);
|
||||
|
@@ -11,10 +11,7 @@ import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transaction.Transaction;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
import org.qortal.utils.ArbitraryTransactionUtils;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.FilesystemUtils;
|
||||
import org.qortal.utils.NTP;
|
||||
import org.qortal.utils.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
@@ -137,7 +134,7 @@ public class ArbitraryDataCleanupManager extends Thread {
|
||||
|
||||
// Fetch the transaction data
|
||||
ArbitraryTransactionData arbitraryTransactionData = ArbitraryTransactionUtils.fetchTransactionData(repository, signature);
|
||||
if (arbitraryTransactionData == null) {
|
||||
if (arbitraryTransactionData == null || arbitraryTransactionData.getService() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -204,7 +201,7 @@ public class ArbitraryDataCleanupManager extends Thread {
|
||||
|
||||
if (completeFileExists && !allChunksExist) {
|
||||
// We have the complete file but not the chunks, so let's convert it
|
||||
LOGGER.info(String.format("Transaction %s has complete file but no chunks",
|
||||
LOGGER.debug(String.format("Transaction %s has complete file but no chunks",
|
||||
Base58.encode(arbitraryTransactionData.getSignature())));
|
||||
|
||||
ArbitraryTransactionUtils.convertFileToChunks(arbitraryTransactionData, now, STALE_FILE_TIMEOUT);
|
||||
@@ -239,7 +236,7 @@ public class ArbitraryDataCleanupManager extends Thread {
|
||||
|
||||
// Delete random data associated with name if we're over our storage limit for this name
|
||||
// Use the DELETION_THRESHOLD, for the same reasons as above
|
||||
for (String followedName : storageManager.followedNames()) {
|
||||
for (String followedName : ListUtils.followedNames()) {
|
||||
if (isStopping) {
|
||||
return;
|
||||
}
|
||||
@@ -349,6 +346,10 @@ public class ArbitraryDataCleanupManager extends Thread {
|
||||
/**
|
||||
* Iteratively walk through given directory and delete a single random file
|
||||
*
|
||||
* TODO: public data should be prioritized over private data
|
||||
* (unless this node is part of a data market contract for that data).
|
||||
* See: Service.privateServices() for a list of services containing private data.
|
||||
*
|
||||
* @param directory - the base directory
|
||||
* @return boolean - whether a file was deleted
|
||||
*/
|
||||
@@ -487,7 +488,7 @@ public class ArbitraryDataCleanupManager extends Thread {
|
||||
|
||||
// Delete data relating to blocked names
|
||||
String name = directory.getName();
|
||||
if (name != null && storageManager.isNameBlocked(name)) {
|
||||
if (name != null && ListUtils.isNameBlocked(name)) {
|
||||
this.safeDeleteDirectory(directory, "blocked name");
|
||||
}
|
||||
|
||||
|
@@ -20,6 +20,7 @@ import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.ListUtils;
|
||||
import org.qortal.utils.NTP;
|
||||
import org.qortal.utils.Triple;
|
||||
|
||||
@@ -123,29 +124,29 @@ public class ArbitraryDataFileListManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Then allow another 3 attempts, each 5 minutes apart
|
||||
if (timeSinceLastAttempt > 5 * 60 * 1000L) {
|
||||
// We haven't tried for at least 5 minutes
|
||||
// Then allow another 5 attempts, each 1 minute apart
|
||||
if (timeSinceLastAttempt > 60 * 1000L) {
|
||||
// We haven't tried for at least 1 minute
|
||||
|
||||
if (networkBroadcastCount < 6) {
|
||||
// We've made less than 6 total attempts
|
||||
if (networkBroadcastCount < 8) {
|
||||
// We've made less than 8 total attempts
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Then allow another 4 attempts, each 30 minutes apart
|
||||
if (timeSinceLastAttempt > 30 * 60 * 1000L) {
|
||||
// We haven't tried for at least 5 minutes
|
||||
// Then allow another 8 attempts, each 15 minutes apart
|
||||
if (timeSinceLastAttempt > 15 * 60 * 1000L) {
|
||||
// We haven't tried for at least 15 minutes
|
||||
|
||||
if (networkBroadcastCount < 10) {
|
||||
// We've made less than 10 total attempts
|
||||
if (networkBroadcastCount < 16) {
|
||||
// We've made less than 16 total attempts
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// From then on, only try once every 24 hours, to reduce network spam
|
||||
if (timeSinceLastAttempt > 24 * 60 * 60 * 1000L) {
|
||||
// We haven't tried for at least 24 hours
|
||||
// From then on, only try once every 6 hours, to reduce network spam
|
||||
if (timeSinceLastAttempt > 6 * 60 * 60 * 1000L) {
|
||||
// We haven't tried for at least 6 hours
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -258,8 +259,6 @@ public class ArbitraryDataFileListManager {
|
||||
// Lookup file lists by signature (and optionally hashes)
|
||||
|
||||
public boolean fetchArbitraryDataFileList(ArbitraryTransactionData arbitraryTransactionData) {
|
||||
byte[] digest = arbitraryTransactionData.getData();
|
||||
byte[] metadataHash = arbitraryTransactionData.getMetadataHash();
|
||||
byte[] signature = arbitraryTransactionData.getSignature();
|
||||
String signature58 = Base58.encode(signature);
|
||||
|
||||
@@ -286,8 +285,7 @@ public class ArbitraryDataFileListManager {
|
||||
|
||||
// Find hashes that we are missing
|
||||
try {
|
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(digest, signature);
|
||||
arbitraryDataFile.setMetadataHash(metadataHash);
|
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(arbitraryTransactionData);
|
||||
missingHashes = arbitraryDataFile.missingHashes();
|
||||
} catch (DataException e) {
|
||||
// Leave missingHashes as null, so that all hashes are requested
|
||||
@@ -460,10 +458,9 @@ public class ArbitraryDataFileListManager {
|
||||
|
||||
arbitraryTransactionData = (ArbitraryTransactionData) transactionData;
|
||||
|
||||
// Load data file(s)
|
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(arbitraryTransactionData.getData(), signature);
|
||||
arbitraryDataFile.setMetadataHash(arbitraryTransactionData.getMetadataHash());
|
||||
|
||||
// // Load data file(s)
|
||||
// ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(arbitraryTransactionData);
|
||||
//
|
||||
// // Check all hashes exist
|
||||
// for (byte[] hash : hashes) {
|
||||
// //LOGGER.debug("Received hash {}", Base58.encode(hash));
|
||||
@@ -507,7 +504,7 @@ public class ArbitraryDataFileListManager {
|
||||
|
||||
// Forwarding
|
||||
if (isRelayRequest && Settings.getInstance().isRelayModeEnabled()) {
|
||||
boolean isBlocked = (arbitraryTransactionData == null || ArbitraryDataStorageManager.getInstance().isNameBlocked(arbitraryTransactionData.getName()));
|
||||
boolean isBlocked = (arbitraryTransactionData == null || ListUtils.isNameBlocked(arbitraryTransactionData.getName()));
|
||||
if (!isBlocked) {
|
||||
Peer requestingPeer = request.getB();
|
||||
if (requestingPeer != null) {
|
||||
@@ -594,12 +591,8 @@ public class ArbitraryDataFileListManager {
|
||||
// Check if we're even allowed to serve data for this transaction
|
||||
if (ArbitraryDataStorageManager.getInstance().canStoreData(transactionData)) {
|
||||
|
||||
byte[] hash = transactionData.getData();
|
||||
byte[] metadataHash = transactionData.getMetadataHash();
|
||||
|
||||
// Load file(s) and add any that exist to the list of hashes
|
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(hash, signature);
|
||||
arbitraryDataFile.setMetadataHash(metadataHash);
|
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData);
|
||||
|
||||
// If the peer didn't supply a hash list, we need to return all hashes for this transaction
|
||||
if (requestedHashes == null || requestedHashes.isEmpty()) {
|
||||
@@ -690,7 +683,7 @@ public class ArbitraryDataFileListManager {
|
||||
}
|
||||
|
||||
// We may need to forward this request on
|
||||
boolean isBlocked = (transactionData == null || ArbitraryDataStorageManager.getInstance().isNameBlocked(transactionData.getName()));
|
||||
boolean isBlocked = (transactionData == null || ListUtils.isNameBlocked(transactionData.getName()));
|
||||
if (Settings.getInstance().isRelayModeEnabled() && !isBlocked) {
|
||||
// In relay mode - so ask our other peers if they have it
|
||||
|
||||
|
@@ -82,7 +82,7 @@ public class ArbitraryDataFileManager extends Thread {
|
||||
|
||||
try {
|
||||
// Use a fixed thread pool to execute the arbitrary data file requests
|
||||
int threadCount = 10;
|
||||
int threadCount = 5;
|
||||
ExecutorService arbitraryDataFileRequestExecutor = Executors.newFixedThreadPool(threadCount);
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
arbitraryDataFileRequestExecutor.execute(new ArbitraryDataFileRequestThread());
|
||||
@@ -132,9 +132,7 @@ public class ArbitraryDataFileManager extends Thread {
|
||||
List<byte[]> hashes) throws DataException {
|
||||
|
||||
// Load data file(s)
|
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(arbitraryTransactionData.getData(), signature);
|
||||
byte[] metadataHash = arbitraryTransactionData.getMetadataHash();
|
||||
arbitraryDataFile.setMetadataHash(metadataHash);
|
||||
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(arbitraryTransactionData);
|
||||
boolean receivedAtLeastOneFile = false;
|
||||
|
||||
// Now fetch actual data from this peer
|
||||
@@ -148,10 +146,10 @@ public class ArbitraryDataFileManager extends Thread {
|
||||
if (!arbitraryDataFileRequests.containsKey(Base58.encode(hash))) {
|
||||
LOGGER.debug("Requesting data file {} from peer {}", hash58, peer);
|
||||
Long startTime = NTP.getTime();
|
||||
ArbitraryDataFileMessage receivedArbitraryDataFileMessage = fetchArbitraryDataFile(peer, null, signature, hash, null);
|
||||
ArbitraryDataFile receivedArbitraryDataFile = fetchArbitraryDataFile(peer, null, signature, hash, null);
|
||||
Long endTime = NTP.getTime();
|
||||
if (receivedArbitraryDataFileMessage != null && receivedArbitraryDataFileMessage.getArbitraryDataFile() != null) {
|
||||
LOGGER.debug("Received data file {} from peer {}. Time taken: {} ms", receivedArbitraryDataFileMessage.getArbitraryDataFile().getHash58(), peer, (endTime-startTime));
|
||||
if (receivedArbitraryDataFile != null) {
|
||||
LOGGER.debug("Received data file {} from peer {}. Time taken: {} ms", receivedArbitraryDataFile.getHash58(), peer, (endTime-startTime));
|
||||
receivedAtLeastOneFile = true;
|
||||
|
||||
// Remove this hash from arbitraryDataFileHashResponses now that we have received it
|
||||
@@ -193,11 +191,11 @@ public class ArbitraryDataFileManager extends Thread {
|
||||
return receivedAtLeastOneFile;
|
||||
}
|
||||
|
||||
private ArbitraryDataFileMessage fetchArbitraryDataFile(Peer peer, Peer requestingPeer, byte[] signature, byte[] hash, Message originalMessage) throws DataException {
|
||||
private ArbitraryDataFile fetchArbitraryDataFile(Peer peer, Peer requestingPeer, byte[] signature, byte[] hash, Message originalMessage) throws DataException {
|
||||
ArbitraryDataFile existingFile = ArbitraryDataFile.fromHash(hash, signature);
|
||||
boolean fileAlreadyExists = existingFile.exists();
|
||||
String hash58 = Base58.encode(hash);
|
||||
ArbitraryDataFileMessage arbitraryDataFileMessage;
|
||||
ArbitraryDataFile arbitraryDataFile;
|
||||
|
||||
// Fetch the file if it doesn't exist locally
|
||||
if (!fileAlreadyExists) {
|
||||
@@ -227,28 +225,32 @@ public class ArbitraryDataFileManager extends Thread {
|
||||
}
|
||||
|
||||
ArbitraryDataFileMessage peersArbitraryDataFileMessage = (ArbitraryDataFileMessage) response;
|
||||
arbitraryDataFileMessage = new ArbitraryDataFileMessage(signature, peersArbitraryDataFileMessage.getArbitraryDataFile());
|
||||
arbitraryDataFile = peersArbitraryDataFileMessage.getArbitraryDataFile();
|
||||
} else {
|
||||
LOGGER.debug(String.format("File hash %s already exists, so skipping the request", hash58));
|
||||
arbitraryDataFileMessage = new ArbitraryDataFileMessage(signature, existingFile);
|
||||
arbitraryDataFile = existingFile;
|
||||
}
|
||||
|
||||
if (arbitraryDataFile == null) {
|
||||
// We don't have a file, so give up here
|
||||
return null;
|
||||
}
|
||||
|
||||
// We might want to forward the request to the peer that originally requested it
|
||||
this.handleArbitraryDataFileForwarding(requestingPeer, arbitraryDataFileMessage, originalMessage);
|
||||
this.handleArbitraryDataFileForwarding(requestingPeer, new ArbitraryDataFileMessage(signature, arbitraryDataFile), originalMessage);
|
||||
|
||||
boolean isRelayRequest = (requestingPeer != null);
|
||||
if (isRelayRequest) {
|
||||
if (!fileAlreadyExists) {
|
||||
// File didn't exist locally before the request, and it's a forwarding request, so delete it
|
||||
LOGGER.debug("Deleting file {} because it was needed for forwarding only", Base58.encode(hash));
|
||||
ArbitraryDataFile dataFile = arbitraryDataFileMessage.getArbitraryDataFile();
|
||||
|
||||
// Keep trying to delete the data until it is deleted, or we reach 10 attempts
|
||||
dataFile.delete(10);
|
||||
arbitraryDataFile.delete(10);
|
||||
}
|
||||
}
|
||||
|
||||
return arbitraryDataFileMessage;
|
||||
return arbitraryDataFile;
|
||||
}
|
||||
|
||||
private void handleFileListRequests(byte[] signature) {
|
||||
@@ -288,7 +290,7 @@ public class ArbitraryDataFileManager extends Thread {
|
||||
// The ID needs to match that of the original request
|
||||
message.setId(originalMessage.getId());
|
||||
|
||||
if (!requestingPeer.sendMessage(message)) {
|
||||
if (!requestingPeer.sendMessageWithTimeout(message, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT)) {
|
||||
LOGGER.debug("Failed to forward arbitrary data file to peer {}", requestingPeer);
|
||||
requestingPeer.disconnect("failed to forward arbitrary data file");
|
||||
}
|
||||
@@ -564,13 +566,16 @@ public class ArbitraryDataFileManager extends Thread {
|
||||
LOGGER.trace("Hash {} exists", hash58);
|
||||
|
||||
// We can serve the file directly as we already have it
|
||||
LOGGER.debug("Sending file {}...", arbitraryDataFile);
|
||||
ArbitraryDataFileMessage arbitraryDataFileMessage = new ArbitraryDataFileMessage(signature, arbitraryDataFile);
|
||||
arbitraryDataFileMessage.setId(message.getId());
|
||||
if (!peer.sendMessage(arbitraryDataFileMessage)) {
|
||||
LOGGER.debug("Couldn't sent file");
|
||||
if (!peer.sendMessageWithTimeout(arbitraryDataFileMessage, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT)) {
|
||||
LOGGER.debug("Couldn't send file {}", arbitraryDataFile);
|
||||
peer.disconnect("failed to send file");
|
||||
}
|
||||
LOGGER.debug("Sent file {}", arbitraryDataFile);
|
||||
else {
|
||||
LOGGER.debug("Sent file {}", arbitraryDataFile);
|
||||
}
|
||||
}
|
||||
else if (relayInfo != null) {
|
||||
LOGGER.debug("We have relay info for hash {}", Base58.encode(hash));
|
||||
|
@@ -114,7 +114,7 @@ public class ArbitraryDataFileRequestThread implements Runnable {
|
||||
return;
|
||||
}
|
||||
|
||||
LOGGER.debug("Fetching file {} from peer {} via request thread...", hash58, peer);
|
||||
LOGGER.trace("Fetching file {} from peer {} via request thread...", hash58, peer);
|
||||
arbitraryDataFileManager.fetchArbitraryDataFiles(repository, peer, signature, arbitraryTransactionData, Arrays.asList(hash));
|
||||
|
||||
} catch (DataException e) {
|
||||
|
@@ -16,7 +16,6 @@ import org.qortal.arbitrary.misc.Service;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.data.transaction.ArbitraryTransactionData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.list.ResourceListManager;
|
||||
import org.qortal.network.Network;
|
||||
import org.qortal.network.Peer;
|
||||
import org.qortal.repository.DataException;
|
||||
@@ -27,6 +26,7 @@ import org.qortal.transaction.ArbitraryTransaction;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
import org.qortal.utils.ArbitraryTransactionUtils;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.ListUtils;
|
||||
import org.qortal.utils.NTP;
|
||||
|
||||
public class ArbitraryDataManager extends Thread {
|
||||
@@ -172,7 +172,7 @@ public class ArbitraryDataManager extends Thread {
|
||||
|
||||
private void processNames() throws InterruptedException {
|
||||
// Fetch latest list of followed names
|
||||
List<String> followedNames = ResourceListManager.getInstance().getStringsInList("followedNames");
|
||||
List<String> followedNames = ListUtils.followedNames();
|
||||
if (followedNames == null || followedNames.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -398,6 +398,11 @@ public class ArbitraryDataManager extends Thread {
|
||||
// Entrypoint to request new metadata from peers
|
||||
public ArbitraryDataTransactionMetadata fetchMetadata(ArbitraryTransactionData arbitraryTransactionData) {
|
||||
|
||||
if (arbitraryTransactionData.getService() == null) {
|
||||
// Can't fetch metadata without a valid service
|
||||
return null;
|
||||
}
|
||||
|
||||
ArbitraryDataResource resource = new ArbitraryDataResource(
|
||||
arbitraryTransactionData.getName(),
|
||||
ArbitraryDataFile.ResourceIdType.NAME,
|
||||
@@ -489,7 +494,7 @@ public class ArbitraryDataManager extends Thread {
|
||||
public void invalidateCache(ArbitraryTransactionData arbitraryTransactionData) {
|
||||
String signature58 = Base58.encode(arbitraryTransactionData.getSignature());
|
||||
|
||||
if (arbitraryTransactionData.getName() != null) {
|
||||
if (arbitraryTransactionData.getName() != null && arbitraryTransactionData.getService() != null) {
|
||||
String resourceId = arbitraryTransactionData.getName().toLowerCase();
|
||||
Service service = arbitraryTransactionData.getService();
|
||||
String identifier = arbitraryTransactionData.getIdentifier();
|
||||
|
@@ -5,15 +5,11 @@ import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.data.transaction.ArbitraryTransactionData;
|
||||
import org.qortal.data.transaction.TransactionData;
|
||||
import org.qortal.list.ResourceListManager;
|
||||
import org.qortal.repository.DataException;
|
||||
import org.qortal.repository.Repository;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transaction.Transaction;
|
||||
import org.qortal.utils.ArbitraryTransactionUtils;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.FilesystemUtils;
|
||||
import org.qortal.utils.NTP;
|
||||
import org.qortal.utils.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
@@ -48,7 +44,6 @@ public class ArbitraryDataStorageManager extends Thread {
|
||||
private List<ArbitraryTransactionData> hostedTransactions;
|
||||
|
||||
private String searchQuery;
|
||||
private List<ArbitraryTransactionData> searchResultsTransactions;
|
||||
|
||||
private static final long DIRECTORY_SIZE_CHECK_INTERVAL = 10 * 60 * 1000L; // 10 minutes
|
||||
|
||||
@@ -136,11 +131,11 @@ public class ArbitraryDataStorageManager extends Thread {
|
||||
case ALL:
|
||||
case VIEWED:
|
||||
// If the policy includes viewed data, we can host it as long as it's not blocked
|
||||
return !this.isNameBlocked(name);
|
||||
return !ListUtils.isNameBlocked(name);
|
||||
|
||||
case FOLLOWED:
|
||||
// If the policy is for followed data only, we have to be following it
|
||||
return this.isFollowingName(name);
|
||||
return ListUtils.isFollowingName(name);
|
||||
|
||||
// For NONE or all else, we shouldn't host this data
|
||||
case NONE:
|
||||
@@ -189,14 +184,14 @@ public class ArbitraryDataStorageManager extends Thread {
|
||||
}
|
||||
|
||||
// Never fetch data from blocked names, even if they are followed
|
||||
if (this.isNameBlocked(name)) {
|
||||
if (ListUtils.isNameBlocked(name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (Settings.getInstance().getStoragePolicy()) {
|
||||
case FOLLOWED:
|
||||
case FOLLOWED_OR_VIEWED:
|
||||
return this.isFollowingName(name);
|
||||
return ListUtils.isFollowingName(name);
|
||||
|
||||
case ALL:
|
||||
return true;
|
||||
@@ -236,7 +231,7 @@ public class ArbitraryDataStorageManager extends Thread {
|
||||
* @return boolean - whether the resource is blocked or not
|
||||
*/
|
||||
public boolean isBlocked(ArbitraryTransactionData arbitraryTransactionData) {
|
||||
return isNameBlocked(arbitraryTransactionData.getName());
|
||||
return ListUtils.isNameBlocked(arbitraryTransactionData.getName());
|
||||
}
|
||||
|
||||
private boolean isDataTypeAllowed(ArbitraryTransactionData arbitraryTransactionData) {
|
||||
@@ -254,22 +249,6 @@ public class ArbitraryDataStorageManager extends Thread {
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean isNameBlocked(String name) {
|
||||
return ResourceListManager.getInstance().listContains("blockedNames", name, false);
|
||||
}
|
||||
|
||||
private boolean isFollowingName(String name) {
|
||||
return ResourceListManager.getInstance().listContains("followedNames", name, false);
|
||||
}
|
||||
|
||||
public List<String> followedNames() {
|
||||
return ResourceListManager.getInstance().getStringsInList("followedNames");
|
||||
}
|
||||
|
||||
private int followedNamesCount() {
|
||||
return ResourceListManager.getInstance().getItemCountForList("followedNames");
|
||||
}
|
||||
|
||||
|
||||
public List<ArbitraryTransactionData> loadAllHostedTransactions(Repository repository) {
|
||||
|
||||
@@ -344,11 +323,6 @@ public class ArbitraryDataStorageManager extends Thread {
|
||||
*/
|
||||
|
||||
public List<ArbitraryTransactionData> searchHostedTransactions(Repository repository, String query, Integer limit, Integer offset) {
|
||||
// Load from results cache if we can (results that exists for the same query), to avoid disk reads
|
||||
if (this.searchResultsTransactions != null && this.searchQuery.equals(query.toLowerCase())) {
|
||||
return ArbitraryTransactionUtils.limitOffsetTransactions(this.searchResultsTransactions, limit, offset);
|
||||
}
|
||||
|
||||
// Using cache if we can, to avoid disk reads
|
||||
if (this.hostedTransactions == null) {
|
||||
this.hostedTransactions = this.loadAllHostedTransactions(repository);
|
||||
@@ -376,10 +350,7 @@ public class ArbitraryDataStorageManager extends Thread {
|
||||
// Sort by newest first
|
||||
searchResultsList.sort(Comparator.comparingLong(ArbitraryTransactionData::getTimestamp).reversed());
|
||||
|
||||
// Update cache
|
||||
this.searchResultsTransactions = searchResultsList;
|
||||
|
||||
return ArbitraryTransactionUtils.limitOffsetTransactions(this.searchResultsTransactions, limit, offset);
|
||||
return ArbitraryTransactionUtils.limitOffsetTransactions(searchResultsList, limit, offset);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -522,7 +493,7 @@ public class ArbitraryDataStorageManager extends Thread {
|
||||
return true;
|
||||
}
|
||||
|
||||
int followedNamesCount = this.followedNamesCount();
|
||||
int followedNamesCount = ListUtils.followedNamesCount();
|
||||
if (followedNamesCount == 0) {
|
||||
// Not following any names, so we have space
|
||||
return true;
|
||||
@@ -552,7 +523,7 @@ public class ArbitraryDataStorageManager extends Thread {
|
||||
}
|
||||
|
||||
public long storageCapacityPerName(double threshold) {
|
||||
int followedNamesCount = this.followedNamesCount();
|
||||
int followedNamesCount = ListUtils.followedNamesCount();
|
||||
if (followedNamesCount == 0) {
|
||||
// Not following any names, so we have the total space available
|
||||
return this.getStorageCapacityIncludingThreshold(threshold);
|
||||
|
@@ -16,6 +16,7 @@ import org.qortal.repository.Repository;
|
||||
import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.utils.Base58;
|
||||
import org.qortal.utils.ListUtils;
|
||||
import org.qortal.utils.NTP;
|
||||
import org.qortal.utils.Triple;
|
||||
|
||||
@@ -101,7 +102,14 @@ public class ArbitraryMetadataManager {
|
||||
if (metadataFile.exists()) {
|
||||
// Use local copy
|
||||
ArbitraryDataTransactionMetadata transactionMetadata = new ArbitraryDataTransactionMetadata(metadataFile.getFilePath());
|
||||
transactionMetadata.read();
|
||||
try {
|
||||
transactionMetadata.read();
|
||||
} catch (DataException e) {
|
||||
// Invalid file, so delete it
|
||||
LOGGER.info("Deleting invalid metadata file due to exception: {}", e.getMessage());
|
||||
transactionMetadata.delete();
|
||||
return null;
|
||||
}
|
||||
return transactionMetadata;
|
||||
}
|
||||
}
|
||||
@@ -332,7 +340,7 @@ public class ArbitraryMetadataManager {
|
||||
}
|
||||
|
||||
// Check if the name is blocked
|
||||
boolean isBlocked = (arbitraryTransactionData == null || ArbitraryDataStorageManager.getInstance().isNameBlocked(arbitraryTransactionData.getName()));
|
||||
boolean isBlocked = (arbitraryTransactionData == null || ListUtils.isNameBlocked(arbitraryTransactionData.getName()));
|
||||
if (!isBlocked) {
|
||||
Peer requestingPeer = request.getB();
|
||||
if (requestingPeer != null) {
|
||||
@@ -420,7 +428,7 @@ public class ArbitraryMetadataManager {
|
||||
}
|
||||
|
||||
// We may need to forward this request on
|
||||
boolean isBlocked = (transactionData == null || ArbitraryDataStorageManager.getInstance().isNameBlocked(transactionData.getName()));
|
||||
boolean isBlocked = (transactionData == null || ListUtils.isNameBlocked(transactionData.getName()));
|
||||
if (Settings.getInstance().isRelayModeEnabled() && !isBlocked) {
|
||||
// In relay mode - so ask our other peers if they have it
|
||||
|
||||
|
@@ -39,9 +39,11 @@ public class AtStatesPruner implements Runnable {
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
int pruneStartHeight = repository.getATRepository().getAtPruneHeight();
|
||||
int maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository);
|
||||
|
||||
repository.discardChanges();
|
||||
repository.getATRepository().rebuildLatestAtStates();
|
||||
repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight);
|
||||
repository.saveChanges();
|
||||
|
||||
while (!Controller.isStopping()) {
|
||||
repository.discardChanges();
|
||||
@@ -91,7 +93,8 @@ public class AtStatesPruner implements Runnable {
|
||||
if (upperPrunableHeight > upperBatchHeight) {
|
||||
pruneStartHeight = upperBatchHeight;
|
||||
repository.getATRepository().setAtPruneHeight(pruneStartHeight);
|
||||
repository.getATRepository().rebuildLatestAtStates();
|
||||
maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository);
|
||||
repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight);
|
||||
repository.saveChanges();
|
||||
|
||||
final int finalPruneStartHeight = pruneStartHeight;
|
||||
|
@@ -26,9 +26,11 @@ public class AtStatesTrimmer implements Runnable {
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
int trimStartHeight = repository.getATRepository().getAtTrimHeight();
|
||||
int maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository);
|
||||
|
||||
repository.discardChanges();
|
||||
repository.getATRepository().rebuildLatestAtStates();
|
||||
repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight);
|
||||
repository.saveChanges();
|
||||
|
||||
while (!Controller.isStopping()) {
|
||||
repository.discardChanges();
|
||||
@@ -69,7 +71,8 @@ public class AtStatesTrimmer implements Runnable {
|
||||
if (upperTrimmableHeight > upperBatchHeight) {
|
||||
trimStartHeight = upperBatchHeight;
|
||||
repository.getATRepository().setAtTrimHeight(trimStartHeight);
|
||||
repository.getATRepository().rebuildLatestAtStates();
|
||||
maxLatestAtStatesHeight = PruneManager.getMaxHeightForLatestAtStates(repository);
|
||||
repository.getATRepository().rebuildLatestAtStates(maxLatestAtStatesHeight);
|
||||
repository.saveChanges();
|
||||
|
||||
final int finalTrimStartHeight = trimStartHeight;
|
||||
|
@@ -0,0 +1,121 @@
|
||||
package org.qortal.controller.repository;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.controller.Controller;
|
||||
import org.qortal.controller.Synchronizer;
|
||||
import org.qortal.repository.*;
|
||||
import org.qortal.settings.Settings;
|
||||
import org.qortal.transform.TransformationException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
|
||||
public class BlockArchiveRebuilder {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(BlockArchiveRebuilder.class);
|
||||
|
||||
private final int serializationVersion;
|
||||
|
||||
public BlockArchiveRebuilder(int serializationVersion) {
|
||||
this.serializationVersion = serializationVersion;
|
||||
}
|
||||
|
||||
public void start() throws DataException, IOException {
|
||||
if (!Settings.getInstance().isArchiveEnabled() || Settings.getInstance().isLite()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// New archive path is in a different location from original archive path, to avoid conflicts.
|
||||
// It will be moved later, once the process is complete.
|
||||
final Path newArchivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive-rebuild");
|
||||
final Path originalArchivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive");
|
||||
|
||||
// Delete archive-rebuild if it exists from a previous attempt
|
||||
FileUtils.deleteDirectory(newArchivePath.toFile());
|
||||
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
int startHeight = 1; // We need to rebuild the entire archive
|
||||
|
||||
LOGGER.info("Rebuilding block archive from height {}...", startHeight);
|
||||
|
||||
while (!Controller.isStopping()) {
|
||||
repository.discardChanges();
|
||||
|
||||
Thread.sleep(1000L);
|
||||
|
||||
// Don't even attempt if we're mid-sync as our repository requests will be delayed for ages
|
||||
if (Synchronizer.getInstance().isSynchronizing()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rebuild archive
|
||||
try {
|
||||
final int maximumArchiveHeight = BlockArchiveReader.getInstance().getHeightOfLastArchivedBlock();
|
||||
if (startHeight >= maximumArchiveHeight) {
|
||||
// We've finished.
|
||||
// Delete existing archive and move the newly built one into its place
|
||||
FileUtils.deleteDirectory(originalArchivePath.toFile());
|
||||
FileUtils.moveDirectory(newArchivePath.toFile(), originalArchivePath.toFile());
|
||||
BlockArchiveReader.getInstance().invalidateFileListCache();
|
||||
LOGGER.info("Block archive successfully rebuilt");
|
||||
return;
|
||||
}
|
||||
|
||||
BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, serializationVersion, newArchivePath, repository);
|
||||
|
||||
// Set data source to BLOCK_ARCHIVE as we are rebuilding
|
||||
writer.setDataSource(BlockArchiveWriter.BlockArchiveDataSource.BLOCK_ARCHIVE);
|
||||
|
||||
// We can't enforce the 100MB file size target, as the final file needs to contain all blocks
|
||||
// that exist in the current archive. Otherwise, the final blocks in the archive will be lost.
|
||||
writer.setShouldEnforceFileSizeTarget(false);
|
||||
|
||||
// We want to log the rebuild progress
|
||||
writer.setShouldLogProgress(true);
|
||||
|
||||
BlockArchiveWriter.BlockArchiveWriteResult result = writer.write();
|
||||
switch (result) {
|
||||
case OK:
|
||||
// Increment block archive height
|
||||
startHeight += writer.getWrittenCount();
|
||||
repository.saveChanges();
|
||||
break;
|
||||
|
||||
case STOPPING:
|
||||
return;
|
||||
|
||||
// We've reached the limit of the blocks we can archive
|
||||
// Sleep for a while to allow more to become available
|
||||
case NOT_ENOUGH_BLOCKS:
|
||||
// This shouldn't happen, as we're not enforcing minimum file sizes
|
||||
repository.discardChanges();
|
||||
throw new DataException("Unable to rebuild archive due to unexpected NOT_ENOUGH_BLOCKS response.");
|
||||
|
||||
case BLOCK_NOT_FOUND:
|
||||
// We tried to archive a block that didn't exist. This is a major failure and likely means
|
||||
// that a bootstrap or re-sync is needed. Try again every minute until then.
|
||||
LOGGER.info("Error: block not found when rebuilding archive. If this error persists, " +
|
||||
"a bootstrap or re-sync may be needed.");
|
||||
repository.discardChanges();
|
||||
throw new DataException("Unable to rebuild archive because a block is missing.");
|
||||
}
|
||||
|
||||
} catch (IOException | TransformationException e) {
|
||||
LOGGER.info("Caught exception when rebuilding block archive", e);
|
||||
throw new DataException("Unable to rebuild block archive");
|
||||
}
|
||||
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
// Do nothing
|
||||
} finally {
|
||||
// Delete archive-rebuild if it still exists, as that means something went wrong
|
||||
FileUtils.deleteDirectory(newArchivePath.toFile());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -13,7 +13,9 @@ import org.qortal.repository.RepositoryManager;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
import org.qortal.utils.Unicode;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class NamesDatabaseIntegrityCheck {
|
||||
|
||||
@@ -28,16 +30,8 @@ public class NamesDatabaseIntegrityCheck {
|
||||
|
||||
private List<TransactionData> nameTransactions = new ArrayList<>();
|
||||
|
||||
|
||||
public int rebuildName(String name, Repository repository) {
|
||||
return this.rebuildName(name, repository, null);
|
||||
}
|
||||
|
||||
public int rebuildName(String name, Repository repository, List<String> referenceNames) {
|
||||
// "referenceNames" tracks the linked names that have already been rebuilt, to prevent circular dependencies
|
||||
if (referenceNames == null) {
|
||||
referenceNames = new ArrayList<>();
|
||||
}
|
||||
|
||||
int modificationCount = 0;
|
||||
try {
|
||||
List<TransactionData> transactions = this.fetchAllTransactionsInvolvingName(name, repository);
|
||||
@@ -46,6 +40,14 @@ public class NamesDatabaseIntegrityCheck {
|
||||
return modificationCount;
|
||||
}
|
||||
|
||||
// If this name has been updated at any point, we need to add transactions from the other names to the sequence
|
||||
int added = this.addAdditionalTransactionsRelatingToName(transactions, name, repository);
|
||||
while (added > 0) {
|
||||
// Keep going until all have been added
|
||||
LOGGER.trace("{} added for {}. Looking for more transactions...", added, name);
|
||||
added = this.addAdditionalTransactionsRelatingToName(transactions, name, repository);
|
||||
}
|
||||
|
||||
// Loop through each past transaction and re-apply it to the Names table
|
||||
for (TransactionData currentTransaction : transactions) {
|
||||
|
||||
@@ -61,29 +63,14 @@ public class NamesDatabaseIntegrityCheck {
|
||||
// Process UPDATE_NAME transactions
|
||||
if (currentTransaction.getType() == TransactionType.UPDATE_NAME) {
|
||||
UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) currentTransaction;
|
||||
|
||||
if (Objects.equals(updateNameTransactionData.getNewName(), name) &&
|
||||
!Objects.equals(updateNameTransactionData.getName(), updateNameTransactionData.getNewName())) {
|
||||
// This renames an existing name, so we need to process that instead
|
||||
|
||||
if (!referenceNames.contains(name)) {
|
||||
referenceNames.add(name);
|
||||
this.rebuildName(updateNameTransactionData.getName(), repository, referenceNames);
|
||||
}
|
||||
else {
|
||||
// We've already processed this name so there's nothing more to do
|
||||
}
|
||||
}
|
||||
else {
|
||||
Name nameObj = new Name(repository, name);
|
||||
if (nameObj != null && nameObj.getNameData() != null) {
|
||||
nameObj.update(updateNameTransactionData);
|
||||
modificationCount++;
|
||||
LOGGER.trace("Processed UPDATE_NAME transaction for name {}", name);
|
||||
} else {
|
||||
// Something went wrong
|
||||
throw new DataException(String.format("Name data not found for name %s", updateNameTransactionData.getName()));
|
||||
}
|
||||
Name nameObj = new Name(repository, updateNameTransactionData.getName());
|
||||
if (nameObj != null && nameObj.getNameData() != null) {
|
||||
nameObj.update(updateNameTransactionData);
|
||||
modificationCount++;
|
||||
LOGGER.trace("Processed UPDATE_NAME transaction for name {}", name);
|
||||
} else {
|
||||
// Something went wrong
|
||||
throw new DataException(String.format("Name data not found for name %s", updateNameTransactionData.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +89,21 @@ public class NamesDatabaseIntegrityCheck {
|
||||
}
|
||||
}
|
||||
|
||||
// Process CANCEL_SELL_NAME transactions
|
||||
if (currentTransaction.getType() == TransactionType.CANCEL_SELL_NAME) {
|
||||
CancelSellNameTransactionData cancelSellNameTransactionData = (CancelSellNameTransactionData) currentTransaction;
|
||||
Name nameObj = new Name(repository, cancelSellNameTransactionData.getName());
|
||||
if (nameObj != null && nameObj.getNameData() != null) {
|
||||
nameObj.cancelSell(cancelSellNameTransactionData);
|
||||
modificationCount++;
|
||||
LOGGER.trace("Processed CANCEL_SELL_NAME transaction for name {}", name);
|
||||
}
|
||||
else {
|
||||
// Something went wrong
|
||||
throw new DataException(String.format("Name data not found for name %s", cancelSellNameTransactionData.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
// Process BUY_NAME transactions
|
||||
if (currentTransaction.getType() == TransactionType.BUY_NAME) {
|
||||
BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) currentTransaction;
|
||||
@@ -128,7 +130,7 @@ public class NamesDatabaseIntegrityCheck {
|
||||
public int rebuildAllNames() {
|
||||
int modificationCount = 0;
|
||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||
List<String> names = this.fetchAllNames(repository);
|
||||
List<String> names = this.fetchAllNames(repository); // TODO: de-duplicate, to speed up this process
|
||||
for (String name : names) {
|
||||
modificationCount += this.rebuildName(name, repository);
|
||||
}
|
||||
@@ -326,6 +328,10 @@ public class NamesDatabaseIntegrityCheck {
|
||||
TransactionType.BUY_NAME, Arrays.asList("name = ?"), Arrays.asList(name));
|
||||
signatures.addAll(buyNameTransactions);
|
||||
|
||||
List<byte[]> cancelSellNameTransactions = repository.getTransactionRepository().getSignaturesMatchingCustomCriteria(
|
||||
TransactionType.CANCEL_SELL_NAME, Arrays.asList("name = ?"), Arrays.asList(name));
|
||||
signatures.addAll(cancelSellNameTransactions);
|
||||
|
||||
List<TransactionData> transactions = new ArrayList<>();
|
||||
for (byte[] signature : signatures) {
|
||||
TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature);
|
||||
@@ -335,8 +341,8 @@ public class NamesDatabaseIntegrityCheck {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by lowest timestamp first
|
||||
transactions.sort(Comparator.comparingLong(TransactionData::getTimestamp));
|
||||
// Sort by lowest block height first
|
||||
sortTransactions(transactions);
|
||||
|
||||
return transactions;
|
||||
}
|
||||
@@ -390,8 +396,77 @@ public class NamesDatabaseIntegrityCheck {
|
||||
names.add(sellNameTransactionData.getName());
|
||||
}
|
||||
}
|
||||
if ((transactionData instanceof CancelSellNameTransactionData)) {
|
||||
CancelSellNameTransactionData cancelSellNameTransactionData = (CancelSellNameTransactionData) transactionData;
|
||||
if (!names.contains(cancelSellNameTransactionData.getName())) {
|
||||
names.add(cancelSellNameTransactionData.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private int addAdditionalTransactionsRelatingToName(List<TransactionData> transactions, String name, Repository repository) throws DataException {
|
||||
int added = 0;
|
||||
|
||||
// If this name has been updated at any point, we need to add transactions from the other names to the sequence
|
||||
List<String> otherNames = new ArrayList<>();
|
||||
List<TransactionData> updateNameTransactions = transactions.stream().filter(t -> t.getType() == TransactionType.UPDATE_NAME).collect(Collectors.toList());
|
||||
for (TransactionData transactionData : updateNameTransactions) {
|
||||
UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) transactionData;
|
||||
// If the newName field isn't empty, and either the "name" or "newName" is different from our reference name,
|
||||
// we should remember this additional name, in case it has relevant transactions associated with it.
|
||||
if (updateNameTransactionData.getNewName() != null && !updateNameTransactionData.getNewName().isEmpty()) {
|
||||
if (!Objects.equals(updateNameTransactionData.getName(), name)) {
|
||||
otherNames.add(updateNameTransactionData.getName());
|
||||
}
|
||||
if (!Objects.equals(updateNameTransactionData.getNewName(), name)) {
|
||||
otherNames.add(updateNameTransactionData.getNewName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (String otherName : otherNames) {
|
||||
List<TransactionData> otherNameTransactions = this.fetchAllTransactionsInvolvingName(otherName, repository);
|
||||
for (TransactionData otherNameTransactionData : otherNameTransactions) {
|
||||
if (!transactions.contains(otherNameTransactionData)) {
|
||||
// Add new transaction relating to other name
|
||||
transactions.add(otherNameTransactionData);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (added > 0) {
|
||||
// New transaction(s) added, so re-sort
|
||||
sortTransactions(transactions);
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
private void sortTransactions(List<TransactionData> transactions) {
|
||||
Collections.sort(transactions, new Comparator() {
|
||||
public int compare(Object o1, Object o2) {
|
||||
TransactionData td1 = (TransactionData) o1;
|
||||
TransactionData td2 = (TransactionData) o2;
|
||||
|
||||
// Sort by block height first
|
||||
int heightComparison = td1.getBlockHeight().compareTo(td2.getBlockHeight());
|
||||
if (heightComparison != 0) {
|
||||
return heightComparison;
|
||||
}
|
||||
|
||||
// Same height so compare timestamps
|
||||
int timestampComparison = Long.compare(td1.getTimestamp(), td2.getTimestamp());
|
||||
if (timestampComparison != 0) {
|
||||
return timestampComparison;
|
||||
}
|
||||
|
||||
// Same timestamp so compare signatures
|
||||
return new BigInteger(td1.getSignature()).compareTo(new BigInteger(td2.getSignature()));
|
||||
}});
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -157,4 +157,18 @@ public class PruneManager {
|
||||
return (height < latestUnprunedHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* When rebuilding the latest AT states, we need to specify a maxHeight, so that we aren't tracking
|
||||
* very recent AT states that could potentially be orphaned. This method ensures that AT states
|
||||
* are given a sufficient number of blocks to confirm before being tracked as a latest AT state.
|
||||
*/
|
||||
public static int getMaxHeightForLatestAtStates(Repository repository) throws DataException {
|
||||
// Get current chain height, and subtract a certain number of "confirmation" blocks
|
||||
// This is to ensure we are basing our latest AT states data on confirmed blocks -
|
||||
// ones that won't be orphaned in any normal circumstances
|
||||
final int confirmationBlocks = 250;
|
||||
final int chainHeight = repository.getBlockRepository().getBlockchainHeight();
|
||||
return chainHeight - confirmationBlocks;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -49,6 +49,7 @@ public class Bitcoin extends Bitcoiny {
|
||||
//CLOSED new Server("bitcoin.grey.pw", Server.ConnectionType.SSL, 50002),
|
||||
//CLOSED new Server("btc.litepay.ch", Server.ConnectionType.SSL, 50002),
|
||||
//CLOSED new Server("electrum.pabu.io", Server.ConnectionType.SSL, 50002),
|
||||
//CLOSED new Server("electrumx.dev", Server.ConnectionType.SSL, 50002),
|
||||
//CLOSED new Server("electrumx.hodlwallet.com", Server.ConnectionType.SSL, 50002),
|
||||
//CLOSED new Server("gd42.org", Server.ConnectionType.SSL, 50002),
|
||||
//CLOSED new Server("korea.electrum-server.com", Server.ConnectionType.SSL, 50002),
|
||||
@@ -56,28 +57,75 @@ public class Bitcoin extends Bitcoiny {
|
||||
//1.15.0 new Server("alviss.coinjoined.com", Server.ConnectionType.SSL, 50002),
|
||||
//1.15.0 new Server("electrum.acinq.co", Server.ConnectionType.SSL, 50002),
|
||||
//1.14.0 new Server("electrum.coinext.com.br", Server.ConnectionType.SSL, 50002),
|
||||
//F1.7.0 new Server("btc.lastingcoin.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("104.248.139.211", Server.ConnectionType.SSL, 50002),
|
||||
new Server("128.0.190.26", Server.ConnectionType.SSL, 50002),
|
||||
new Server("142.93.6.38", Server.ConnectionType.SSL, 50002),
|
||||
new Server("157.245.172.236", Server.ConnectionType.SSL, 50002),
|
||||
new Server("167.172.226.175", Server.ConnectionType.SSL, 50002),
|
||||
new Server("167.172.42.31", Server.ConnectionType.SSL, 50002),
|
||||
new Server("178.62.80.20", Server.ConnectionType.SSL, 50002),
|
||||
new Server("185.64.116.15", Server.ConnectionType.SSL, 50002),
|
||||
new Server("188.165.206.215", Server.ConnectionType.SSL, 50002),
|
||||
new Server("188.165.211.112", Server.ConnectionType.SSL, 50002),
|
||||
new Server("2azzarita.hopto.org", Server.ConnectionType.SSL, 50002),
|
||||
new Server("2electrumx.hopto.me", Server.ConnectionType.SSL, 56022),
|
||||
new Server("2ex.digitaleveryware.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("65.39.140.37", Server.ConnectionType.SSL, 50002),
|
||||
new Server("68.183.188.105", Server.ConnectionType.SSL, 50002),
|
||||
new Server("71.73.14.254", Server.ConnectionType.SSL, 50002),
|
||||
new Server("94.23.247.135", Server.ConnectionType.SSL, 50002),
|
||||
new Server("assuredly.not.fyi", Server.ConnectionType.SSL, 50002),
|
||||
new Server("ax101.blockeng.ch", Server.ConnectionType.SSL, 50002),
|
||||
new Server("ax102.blockeng.ch", Server.ConnectionType.SSL, 50002),
|
||||
new Server("b.1209k.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("b6.1209k.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("bitcoin.dermichi.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("bitcoin.lu.ke", Server.ConnectionType.SSL, 50002),
|
||||
new Server("bitcoin.lukechilds.co", Server.ConnectionType.SSL, 50002),
|
||||
new Server("blkhub.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("btc.lastingcoin.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("btc.electroncash.dk", Server.ConnectionType.SSL, 60002),
|
||||
new Server("btc.ocf.sh", Server.ConnectionType.SSL, 50002),
|
||||
new Server("btce.iiiiiii.biz", Server.ConnectionType.SSL, 50002),
|
||||
new Server("caleb.vegas", Server.ConnectionType.SSL, 50002),
|
||||
new Server("eai.coincited.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.bhoovd.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.bitaroo.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrumx.dev", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.bitcoinlizard.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.blockstream.info", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.emzy.de", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.exan.tech", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.kendigisland.xyz", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.mmitech.info", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.petrkr.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.stippy.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.thomasfischbach.de", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum0.snel.it", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum1.cipig.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum2.cipig.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum3.cipig.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrumx.alexridevski.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrumx-core.1209k.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("elx.bitske.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("ex03.axalgo.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("ex05.axalgo.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("ex07.axalgo.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("fortress.qtornado.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("fulcrum.grey.pw", Server.ConnectionType.SSL, 50002),
|
||||
new Server("fulcrum.sethforprivacy.com", Server.ConnectionType.SSL, 51002),
|
||||
new Server("guichet.centure.cc", Server.ConnectionType.SSL, 50002),
|
||||
new Server("kareoke.qoppa.org", Server.ConnectionType.SSL, 50002),
|
||||
new Server("hodlers.beer", Server.ConnectionType.SSL, 50002),
|
||||
new Server("kareoke.qoppa.org", Server.ConnectionType.SSL, 50002),
|
||||
new Server("kirsche.emzy.de", Server.ConnectionType.SSL, 50002),
|
||||
new Server("node1.btccuracao.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("osr1ex1.compumundohipermegared.one", Server.ConnectionType.SSL, 50002),
|
||||
new Server("smmalis37.ddns.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("ulrichard.ch", Server.ConnectionType.SSL, 50002),
|
||||
new Server("vmd104012.contaboserver.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("vmd104014.contaboserver.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("vmd63185.contaboserver.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("vmd71287.contaboserver.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("vmd84592.contaboserver.net", Server.ConnectionType.SSL, 50002),
|
||||
new Server("xtrum.com", Server.ConnectionType.SSL, 50002));
|
||||
}
|
||||
|
||||
|
@@ -167,6 +167,16 @@ public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
return blockTimestamps.get(5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns height from latest block.
|
||||
* <p>
|
||||
* @throws ForeignBlockchainException if error occurs
|
||||
*/
|
||||
public int getBlockchainHeight() throws ForeignBlockchainException {
|
||||
int height = this.blockchainProvider.getCurrentHeight();
|
||||
return height;
|
||||
}
|
||||
|
||||
/** Returns fee per transaction KB. To be overridden for testnet/regtest. */
|
||||
public Coin getFeePerKb() {
|
||||
return this.bitcoinjContext.getFeePerKb();
|
||||
@@ -357,19 +367,33 @@ public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
* @return unspent BTC balance, or null if unable to determine balance
|
||||
*/
|
||||
public Long getWalletBalance(String key58) throws ForeignBlockchainException {
|
||||
// It's more accurate to calculate the balance from the transactions, rather than asking Bitcoinj
|
||||
return this.getWalletBalanceFromTransactions(key58);
|
||||
Long balance = 0L;
|
||||
|
||||
// Context.propagate(bitcoinjContext);
|
||||
//
|
||||
// Wallet wallet = walletFromDeterministicKey58(key58);
|
||||
// wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet));
|
||||
//
|
||||
// Coin balance = wallet.getBalance();
|
||||
// if (balance == null)
|
||||
// return null;
|
||||
//
|
||||
// return balance.value;
|
||||
List<TransactionOutput> allUnspentOutputs = new ArrayList<>();
|
||||
Set<String> walletAddresses = this.getWalletAddresses(key58);
|
||||
for (String address : walletAddresses) {
|
||||
allUnspentOutputs.addAll(this.getUnspentOutputs(address));
|
||||
}
|
||||
for (TransactionOutput output : allUnspentOutputs) {
|
||||
if (!output.isAvailableForSpending()) {
|
||||
continue;
|
||||
}
|
||||
balance += output.getValue().value;
|
||||
}
|
||||
return balance;
|
||||
}
|
||||
|
||||
public Long getWalletBalanceFromBitcoinj(String key58) {
|
||||
Context.propagate(bitcoinjContext);
|
||||
|
||||
Wallet wallet = walletFromDeterministicKey58(key58);
|
||||
wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet));
|
||||
|
||||
Coin balance = wallet.getBalance();
|
||||
if (balance == null)
|
||||
return null;
|
||||
|
||||
return balance.value;
|
||||
}
|
||||
|
||||
public Long getWalletBalanceFromTransactions(String key58) throws ForeignBlockchainException {
|
||||
@@ -464,6 +488,64 @@ public abstract class Bitcoiny implements ForeignBlockchain {
|
||||
}
|
||||
}
|
||||
|
||||
public Set<String> getWalletAddresses(String key58) throws ForeignBlockchainException {
|
||||
synchronized (this) {
|
||||
Context.propagate(bitcoinjContext);
|
||||
|
||||
Wallet wallet = walletFromDeterministicKey58(key58);
|
||||
DeterministicKeyChain keyChain = wallet.getActiveKeyChain();
|
||||
|
||||
keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT);
|
||||
keyChain.maybeLookAhead();
|
||||
|
||||
List<DeterministicKey> keys = new ArrayList<>(keyChain.getLeafKeys());
|
||||
|
||||
Set<String> keySet = new HashSet<>();
|
||||
|
||||
int unusedCounter = 0;
|
||||
int ki = 0;
|
||||
do {
|
||||
boolean areAllKeysUnused = true;
|
||||
|
||||
for (; ki < keys.size(); ++ki) {
|
||||
DeterministicKey dKey = keys.get(ki);
|
||||
|
||||
// Check for transactions
|
||||
Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH);
|
||||
keySet.add(address.toString());
|
||||
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
||||
|
||||
// Ask for transaction history - if it's empty then key has never been used
|
||||
List<TransactionHash> historicTransactionHashes = this.getAddressTransactions(script, false);
|
||||
|
||||
if (!historicTransactionHashes.isEmpty()) {
|
||||
areAllKeysUnused = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (areAllKeysUnused) {
|
||||
// No transactions
|
||||
if (unusedCounter >= Settings.getInstance().getGapLimit()) {
|
||||
// ... and we've hit our search limit
|
||||
break;
|
||||
}
|
||||
// We haven't hit our search limit yet so increment the counter and keep looking
|
||||
unusedCounter += WALLET_KEY_LOOKAHEAD_INCREMENT;
|
||||
} else {
|
||||
// Some keys in this batch were used, so reset the counter
|
||||
unusedCounter = 0;
|
||||
}
|
||||
|
||||
// Generate some more keys
|
||||
keys.addAll(generateMoreKeys(keyChain));
|
||||
|
||||
// Process new keys
|
||||
} while (true);
|
||||
|
||||
return keySet;
|
||||
}
|
||||
}
|
||||
|
||||
protected SimpleTransaction convertToSimpleTransaction(BitcoinyTransaction t, Set<String> keySet) {
|
||||
long amount = 0;
|
||||
long total = 0L;
|
||||
|
@@ -45,6 +45,9 @@ public class Digibyte extends Bitcoiny {
|
||||
return Arrays.asList(
|
||||
// Servers chosen on NO BASIS WHATSOEVER from various sources!
|
||||
// Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=dgb
|
||||
new Server("electrum.qortal.link", Server.ConnectionType.SSL, 55002),
|
||||
new Server("electrum-dgb.qortal.online", ConnectionType.SSL, 50002),
|
||||
new Server("electrum1-dgb.qortal.online", ConnectionType.SSL, 50002),
|
||||
new Server("electrum1.cipig.net", ConnectionType.SSL, 20059),
|
||||
new Server("electrum2.cipig.net", ConnectionType.SSL, 20059),
|
||||
new Server("electrum3.cipig.net", ConnectionType.SSL, 20059));
|
||||
@@ -134,6 +137,8 @@ public class Digibyte extends Bitcoiny {
|
||||
Context bitcoinjContext = new Context(digibyteNet.getParams());
|
||||
|
||||
instance = new Digibyte(digibyteNet, electrumX, bitcoinjContext, CURRENCY_CODE);
|
||||
|
||||
electrumX.setBlockchain(instance);
|
||||
}
|
||||
|
||||
return instance;
|
||||
|
@@ -45,10 +45,13 @@ public class Dogecoin extends Bitcoiny {
|
||||
public Collection<Server> getServers() {
|
||||
return Arrays.asList(
|
||||
// Servers chosen on NO BASIS WHATSOEVER from various sources!
|
||||
// Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=doge
|
||||
new Server("electrum.qortal.link", Server.ConnectionType.SSL, 54002),
|
||||
new Server("electrum-doge.qortal.online", ConnectionType.SSL, 50002),
|
||||
new Server("electrum1-doge.qortal.online", ConnectionType.SSL, 50002),
|
||||
new Server("electrum1.cipig.net", ConnectionType.SSL, 20060),
|
||||
new Server("electrum2.cipig.net", ConnectionType.SSL, 20060),
|
||||
new Server("electrum3.cipig.net", ConnectionType.SSL, 20060));
|
||||
// TODO: add more mainnet servers. It's too centralized.
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@@ -5,6 +5,7 @@ import java.math.BigDecimal;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketAddress;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -30,7 +31,11 @@ public class ElectrumX extends BitcoinyBlockchainProvider {
|
||||
private static final Logger LOGGER = LogManager.getLogger(ElectrumX.class);
|
||||
private static final Random RANDOM = new Random();
|
||||
|
||||
// See: https://electrumx.readthedocs.io/en/latest/protocol-changes.html
|
||||
private static final double MIN_PROTOCOL_VERSION = 1.2;
|
||||
private static final double MAX_PROTOCOL_VERSION = 2.0; // Higher than current latest, for hopeful future-proofing
|
||||
private static final String CLIENT_NAME = "Qortal";
|
||||
|
||||
private static final int BLOCK_HEADER_LENGTH = 80;
|
||||
|
||||
// "message": "daemon error: DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})"
|
||||
@@ -679,6 +684,9 @@ public class ElectrumX extends BitcoinyBlockchainProvider {
|
||||
this.scanner = new Scanner(this.socket.getInputStream());
|
||||
this.scanner.useDelimiter("\n");
|
||||
|
||||
// All connections need to start with a version negotiation
|
||||
this.connectedRpc("server.version");
|
||||
|
||||
// Check connection is suitable by asking for server features, including genesis block hash
|
||||
JSONObject featuresJson = (JSONObject) this.connectedRpc("server.features");
|
||||
|
||||
@@ -725,6 +733,17 @@ public class ElectrumX extends BitcoinyBlockchainProvider {
|
||||
|
||||
JSONArray requestParams = new JSONArray();
|
||||
requestParams.addAll(Arrays.asList(params));
|
||||
|
||||
// server.version needs additional params to negotiate a version
|
||||
if (method.equals("server.version")) {
|
||||
requestParams.add(CLIENT_NAME);
|
||||
List<String> versions = new ArrayList<>();
|
||||
DecimalFormat df = new DecimalFormat("#.#");
|
||||
versions.add(df.format(MIN_PROTOCOL_VERSION));
|
||||
versions.add(df.format(MAX_PROTOCOL_VERSION));
|
||||
requestParams.add(versions);
|
||||
}
|
||||
|
||||
requestJson.put("params", requestParams);
|
||||
|
||||
String request = requestJson.toJSONString() + "\n";
|
||||
|
@@ -45,15 +45,19 @@ public class Litecoin extends Bitcoiny {
|
||||
return Arrays.asList(
|
||||
// Servers chosen on NO BASIS WHATSOEVER from various sources!
|
||||
// Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=ltc
|
||||
//CLOSED new Server("electrum-ltc.petrkr.net", Server.ConnectionType.SSL, 60002),
|
||||
//CLOSED new Server("electrum-ltc.someguy123.net", Server.ConnectionType.SSL, 50002),
|
||||
//CLOSED new Server("ltc.litepay.ch", Server.ConnectionType.SSL, 50022),
|
||||
//BEHIND new Server("62.171.169.176", Server.ConnectionType.SSL, 50002),
|
||||
//PHISHY new Server("electrum-ltc.bysh.me", Server.ConnectionType.SSL, 50002),
|
||||
new Server("backup.electrum-ltc.org", Server.ConnectionType.SSL, 443),
|
||||
new Server("electrum.qortal.link", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum.ltc.xurious.com", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum-ltc.petrkr.net", Server.ConnectionType.SSL, 60002),
|
||||
new Server("electrum-ltc.qortal.online", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum1-ltc.qortal.online", Server.ConnectionType.SSL, 50002),
|
||||
new Server("electrum1.cipig.net", Server.ConnectionType.SSL, 20063),
|
||||
new Server("electrum2.cipig.net", Server.ConnectionType.SSL, 20063),
|
||||
new Server("electrum3.cipig.net", Server.ConnectionType.SSL, 20063),
|
||||
new Server("ltc.litepay.ch", Server.ConnectionType.SSL, 50022),
|
||||
new Server("ltc.rentonrisk.com", Server.ConnectionType.SSL, 50002));
|
||||
}
|
||||
|
||||
|
@@ -57,9 +57,9 @@ public class PirateChain extends Bitcoiny {
|
||||
public Collection<Server> getServers() {
|
||||
return Arrays.asList(
|
||||
// Servers chosen on NO BASIS WHATSOEVER from various sources!
|
||||
new Server("arrrlightd.qortal.online", ConnectionType.SSL, 443),
|
||||
new Server("arrrlightd1.qortal.online", ConnectionType.SSL, 443),
|
||||
new Server("arrrlightd2.qortal.online", ConnectionType.SSL, 443),
|
||||
new Server("wallet-arrr1.qortal.online", ConnectionType.SSL, 443),
|
||||
new Server("wallet-arrr2.qortal.online", ConnectionType.SSL, 443),
|
||||
new Server("wallet-arrr3.qortal.online", ConnectionType.SSL, 443),
|
||||
new Server("lightd.pirate.black", ConnectionType.SSL, 443));
|
||||
}
|
||||
|
||||
|
@@ -45,13 +45,17 @@ public class Ravencoin extends Bitcoiny {
|
||||
return Arrays.asList(
|
||||
// Servers chosen on NO BASIS WHATSOEVER from various sources!
|
||||
// Status verified at https://1209k.com/bitcoin-eye/ele.php?chain=rvn
|
||||
new Server("aethyn.com", ConnectionType.SSL, 50002),
|
||||
new Server("electrum2.rvn.rocks", ConnectionType.SSL, 50002),
|
||||
new Server("rvn-dashboard.com", ConnectionType.SSL, 50002),
|
||||
new Server("rvn4lyfe.com", ConnectionType.SSL, 50002),
|
||||
//CLOSED new Server("aethyn.com", ConnectionType.SSL, 50002),
|
||||
//CLOSED new Server("electrum2.rvn.rocks", ConnectionType.SSL, 50002),
|
||||
//BEHIND new Server("electrum3.rvn.rocks", ConnectionType.SSL, 50002),
|
||||
new Server("electrum.qortal.link", Server.ConnectionType.SSL, 56002),
|
||||
new Server("electrum-rvn.qortal.online", ConnectionType.SSL, 50002),
|
||||
new Server("electrum1-rvn.qortal.online", ConnectionType.SSL, 50002),
|
||||
new Server("electrum1.cipig.net", ConnectionType.SSL, 20051),
|
||||
new Server("electrum2.cipig.net", ConnectionType.SSL, 20051),
|
||||
new Server("electrum3.cipig.net", ConnectionType.SSL, 20051));
|
||||
new Server("electrum3.cipig.net", ConnectionType.SSL, 20051),
|
||||
new Server("rvn-dashboard.com", ConnectionType.SSL, 50002),
|
||||
new Server("rvn4lyfe.com", ConnectionType.SSL, 50002));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -138,6 +142,8 @@ public class Ravencoin extends Bitcoiny {
|
||||
Context bitcoinjContext = new Context(ravencoinNet.getParams());
|
||||
|
||||
instance = new Ravencoin(ravencoinNet, electrumX, bitcoinjContext, CURRENCY_CODE);
|
||||
|
||||
electrumX.setBlockchain(instance);
|
||||
}
|
||||
|
||||
return instance;
|
||||
|
@@ -202,4 +202,12 @@ public class AES {
|
||||
.decode(cipherText)));
|
||||
}
|
||||
|
||||
public static long getEncryptedFileSize(long inFileSize) {
|
||||
// To calculate the resulting file size, add 16 (for the IV), then round up to the nearest multiple of 16
|
||||
final int ivSize = 16;
|
||||
final int chunkSize = 16;
|
||||
final int expectedSize = Math.round((inFileSize + ivSize) / chunkSize) * chunkSize + chunkSize;
|
||||
return expectedSize;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -16,10 +16,17 @@ public class ArbitraryResourceInfo {
|
||||
public ArbitraryResourceMetadata metadata;
|
||||
|
||||
public Long size;
|
||||
public Long created;
|
||||
public Long updated;
|
||||
|
||||
public ArbitraryResourceInfo() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("%s %s %s", name, service, identifier);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (o == this)
|
||||
|
@@ -15,22 +15,26 @@ public class ArbitraryResourceMetadata {
|
||||
private List<String> tags;
|
||||
private Category category;
|
||||
private String categoryName;
|
||||
private List<String> files;
|
||||
private String mimeType;
|
||||
|
||||
public ArbitraryResourceMetadata() {
|
||||
}
|
||||
|
||||
public ArbitraryResourceMetadata(String title, String description, List<String> tags, Category category) {
|
||||
public ArbitraryResourceMetadata(String title, String description, List<String> tags, Category category, List<String> files, String mimeType) {
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.tags = tags;
|
||||
this.category = category;
|
||||
this.files = files;
|
||||
this.mimeType = mimeType;
|
||||
|
||||
if (category != null) {
|
||||
this.categoryName = category.getName();
|
||||
}
|
||||
}
|
||||
|
||||
public static ArbitraryResourceMetadata fromTransactionMetadata(ArbitraryDataTransactionMetadata transactionMetadata) {
|
||||
public static ArbitraryResourceMetadata fromTransactionMetadata(ArbitraryDataTransactionMetadata transactionMetadata, boolean includeFileList) {
|
||||
if (transactionMetadata == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -38,11 +42,22 @@ public class ArbitraryResourceMetadata {
|
||||
String description = transactionMetadata.getDescription();
|
||||
List<String> tags = transactionMetadata.getTags();
|
||||
Category category = transactionMetadata.getCategory();
|
||||
String mimeType = transactionMetadata.getMimeType();
|
||||
|
||||
if (title == null && description == null && tags == null && category == null) {
|
||||
// We don't always want to include the file list as it can be too verbose
|
||||
List<String> files = null;
|
||||
if (includeFileList) {
|
||||
files = transactionMetadata.getFiles();
|
||||
}
|
||||
|
||||
if (title == null && description == null && tags == null && category == null && files == null && mimeType == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ArbitraryResourceMetadata(title, description, tags, category);
|
||||
return new ArbitraryResourceMetadata(title, description, tags, category, files, mimeType);
|
||||
}
|
||||
|
||||
public List<String> getFiles() {
|
||||
return this.files;
|
||||
}
|
||||
}
|
||||
|
@@ -8,6 +8,7 @@ public class ArbitraryResourceStatus {
|
||||
|
||||
public enum Status {
|
||||
PUBLISHED("Published", "Published but not yet downloaded"),
|
||||
NOT_PUBLISHED("Not published", "Resource does not exist"),
|
||||
DOWNLOADING("Downloading", "Locating and downloading files..."),
|
||||
DOWNLOADED("Downloaded", "Files downloaded"),
|
||||
BUILDING("Building", "Building..."),
|
||||
@@ -33,6 +34,7 @@ public class ArbitraryResourceStatus {
|
||||
|
||||
private Integer localChunkCount;
|
||||
private Integer totalChunkCount;
|
||||
private Float percentLoaded;
|
||||
|
||||
public ArbitraryResourceStatus() {
|
||||
}
|
||||
@@ -44,6 +46,7 @@ public class ArbitraryResourceStatus {
|
||||
this.description = status.description;
|
||||
this.localChunkCount = localChunkCount;
|
||||
this.totalChunkCount = totalChunkCount;
|
||||
this.percentLoaded = (this.localChunkCount != null && this.totalChunkCount != null && this.totalChunkCount > 0) ? this.localChunkCount / (float)this.totalChunkCount * 100.0f : null;
|
||||
}
|
||||
|
||||
public ArbitraryResourceStatus(Status status) {
|
||||
|
@@ -1,10 +1,15 @@
|
||||
package org.qortal.data.chat;
|
||||
|
||||
import org.bouncycastle.util.encoders.Base64;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import static org.qortal.data.chat.ChatMessage.Encoding;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class ActiveChats {
|
||||
|
||||
@@ -17,17 +22,39 @@ public class ActiveChats {
|
||||
private Long timestamp;
|
||||
private String sender;
|
||||
private String senderName;
|
||||
private byte[] signature;
|
||||
private Encoding encoding;
|
||||
private String data;
|
||||
|
||||
protected GroupChat() {
|
||||
/* JAXB */
|
||||
}
|
||||
|
||||
public GroupChat(int groupId, String groupName, Long timestamp, String sender, String senderName) {
|
||||
public GroupChat(int groupId, String groupName, Long timestamp, String sender, String senderName,
|
||||
byte[] signature, Encoding encoding, byte[] data) {
|
||||
this.groupId = groupId;
|
||||
this.groupName = groupName;
|
||||
this.timestamp = timestamp;
|
||||
this.sender = sender;
|
||||
this.senderName = senderName;
|
||||
this.signature = signature;
|
||||
this.encoding = encoding != null ? encoding : Encoding.BASE58;
|
||||
|
||||
if (data != null) {
|
||||
switch (this.encoding) {
|
||||
case BASE64:
|
||||
this.data = Base64.toBase64String(data);
|
||||
break;
|
||||
|
||||
case BASE58:
|
||||
default:
|
||||
this.data = Base58.encode(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.data = null;
|
||||
}
|
||||
}
|
||||
|
||||
public int getGroupId() {
|
||||
@@ -49,6 +76,14 @@ public class ActiveChats {
|
||||
public String getSenderName() {
|
||||
return this.senderName;
|
||||
}
|
||||
|
||||
public byte[] getSignature() {
|
||||
return this.signature;
|
||||
}
|
||||
|
||||
public String getData() {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
@@ -118,4 +153,4 @@ public class ActiveChats {
|
||||
return this.direct;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@@ -1,11 +1,19 @@
|
||||
package org.qortal.data.chat;
|
||||
|
||||
import org.bouncycastle.util.encoders.Base64;
|
||||
import org.qortal.utils.Base58;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class ChatMessage {
|
||||
|
||||
public enum Encoding {
|
||||
BASE58,
|
||||
BASE64
|
||||
}
|
||||
|
||||
// Properties
|
||||
|
||||
private long timestamp;
|
||||
@@ -27,7 +35,11 @@ public class ChatMessage {
|
||||
|
||||
private String recipientName;
|
||||
|
||||
private byte[] data;
|
||||
private byte[] chatReference;
|
||||
|
||||
private Encoding encoding;
|
||||
|
||||
private String data;
|
||||
|
||||
private boolean isText;
|
||||
private boolean isEncrypted;
|
||||
@@ -42,8 +54,8 @@ public class ChatMessage {
|
||||
|
||||
// For repository use
|
||||
public ChatMessage(long timestamp, int txGroupId, byte[] reference, byte[] senderPublicKey, String sender,
|
||||
String senderName, String recipient, String recipientName, byte[] data, boolean isText,
|
||||
boolean isEncrypted, byte[] signature) {
|
||||
String senderName, String recipient, String recipientName, byte[] chatReference,
|
||||
Encoding encoding, byte[] data, boolean isText, boolean isEncrypted, byte[] signature) {
|
||||
this.timestamp = timestamp;
|
||||
this.txGroupId = txGroupId;
|
||||
this.reference = reference;
|
||||
@@ -52,7 +64,25 @@ public class ChatMessage {
|
||||
this.senderName = senderName;
|
||||
this.recipient = recipient;
|
||||
this.recipientName = recipientName;
|
||||
this.data = data;
|
||||
this.chatReference = chatReference;
|
||||
this.encoding = encoding != null ? encoding : Encoding.BASE58;
|
||||
|
||||
if (data != null) {
|
||||
switch (this.encoding) {
|
||||
case BASE64:
|
||||
this.data = Base64.toBase64String(data);
|
||||
break;
|
||||
|
||||
case BASE58:
|
||||
default:
|
||||
this.data = Base58.encode(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.data = null;
|
||||
}
|
||||
|
||||
this.isText = isText;
|
||||
this.isEncrypted = isEncrypted;
|
||||
this.signature = signature;
|
||||
@@ -90,7 +120,11 @@ public class ChatMessage {
|
||||
return this.recipientName;
|
||||
}
|
||||
|
||||
public byte[] getData() {
|
||||
public byte[] getChatReference() {
|
||||
return this.chatReference;
|
||||
}
|
||||
|
||||
public String getData() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
|
@@ -73,7 +73,7 @@ public class ArbitraryTransactionData extends TransactionData {
|
||||
@Schema(example = "sender_public_key")
|
||||
private byte[] senderPublicKey;
|
||||
|
||||
private Service service;
|
||||
private int service;
|
||||
private int nonce;
|
||||
private int size;
|
||||
|
||||
@@ -103,7 +103,7 @@ public class ArbitraryTransactionData extends TransactionData {
|
||||
}
|
||||
|
||||
public ArbitraryTransactionData(BaseTransactionData baseTransactionData,
|
||||
int version, Service service, int nonce, int size,
|
||||
int version, int service, int nonce, int size,
|
||||
String name, String identifier, Method method, byte[] secret, Compression compression,
|
||||
byte[] data, DataType dataType, byte[] metadataHash, List<PaymentData> payments) {
|
||||
super(TransactionType.ARBITRARY, baseTransactionData);
|
||||
@@ -135,6 +135,10 @@ public class ArbitraryTransactionData extends TransactionData {
|
||||
}
|
||||
|
||||
public Service getService() {
|
||||
return Service.valueOf(this.service);
|
||||
}
|
||||
|
||||
public int getServiceInt() {
|
||||
return this.service;
|
||||
}
|
||||
|
||||
|
@@ -3,6 +3,7 @@ package org.qortal.data.transaction;
|
||||
import javax.xml.bind.Unmarshaller;
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.XmlTransient;
|
||||
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
|
||||
@@ -19,6 +20,11 @@ public class CancelSellNameTransactionData extends TransactionData {
|
||||
@Schema(description = "which name to cancel selling", example = "my-name")
|
||||
private String name;
|
||||
|
||||
// For internal use when orphaning
|
||||
@XmlTransient
|
||||
@Schema(hidden = true)
|
||||
private Long salePrice;
|
||||
|
||||
// Constructors
|
||||
|
||||
// For JAXB
|
||||
@@ -30,11 +36,17 @@ public class CancelSellNameTransactionData extends TransactionData {
|
||||
this.creatorPublicKey = this.ownerPublicKey;
|
||||
}
|
||||
|
||||
public CancelSellNameTransactionData(BaseTransactionData baseTransactionData, String name) {
|
||||
public CancelSellNameTransactionData(BaseTransactionData baseTransactionData, String name, Long salePrice) {
|
||||
super(TransactionType.CANCEL_SELL_NAME, baseTransactionData);
|
||||
|
||||
this.ownerPublicKey = baseTransactionData.creatorPublicKey;
|
||||
this.name = name;
|
||||
this.salePrice = salePrice;
|
||||
}
|
||||
|
||||
/** From network/API */
|
||||
public CancelSellNameTransactionData(BaseTransactionData baseTransactionData, String name) {
|
||||
this(baseTransactionData, name, null);
|
||||
}
|
||||
|
||||
// Getters / setters
|
||||
@@ -47,4 +59,12 @@ public class CancelSellNameTransactionData extends TransactionData {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public Long getSalePrice() {
|
||||
return this.salePrice;
|
||||
}
|
||||
|
||||
public void setSalePrice(Long salePrice) {
|
||||
this.salePrice = salePrice;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -26,6 +26,8 @@ public class ChatTransactionData extends TransactionData {
|
||||
|
||||
private String recipient; // can be null
|
||||
|
||||
private byte[] chatReference; // can be null
|
||||
|
||||
@Schema(description = "raw message data, possibly UTF8 text", example = "2yGEbwRFyhPZZckKA")
|
||||
private byte[] data;
|
||||
|
||||
@@ -44,13 +46,14 @@ public class ChatTransactionData extends TransactionData {
|
||||
}
|
||||
|
||||
public ChatTransactionData(BaseTransactionData baseTransactionData,
|
||||
String sender, int nonce, String recipient, byte[] data, boolean isText, boolean isEncrypted) {
|
||||
String sender, int nonce, String recipient, byte[] chatReference, byte[] data, boolean isText, boolean isEncrypted) {
|
||||
super(TransactionType.CHAT, baseTransactionData);
|
||||
|
||||
this.senderPublicKey = baseTransactionData.creatorPublicKey;
|
||||
this.sender = sender;
|
||||
this.nonce = nonce;
|
||||
this.recipient = recipient;
|
||||
this.chatReference = chatReference;
|
||||
this.data = data;
|
||||
this.isText = isText;
|
||||
this.isEncrypted = isEncrypted;
|
||||
@@ -78,6 +81,14 @@ public class ChatTransactionData extends TransactionData {
|
||||
return this.recipient;
|
||||
}
|
||||
|
||||
public byte[] getChatReference() {
|
||||
return this.chatReference;
|
||||
}
|
||||
|
||||
public void setChatReference(byte[] chatReference) {
|
||||
this.chatReference = chatReference;
|
||||
}
|
||||
|
||||
public byte[] getData() {
|
||||
return this.data;
|
||||
}
|
||||
|
@@ -2,9 +2,11 @@ package org.qortal.data.transaction;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import javax.xml.bind.Unmarshaller;
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
|
||||
import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorValue;
|
||||
import org.qortal.data.voting.PollOptionData;
|
||||
import org.qortal.transaction.Transaction;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
@@ -14,8 +16,13 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
// All properties to be converted to JSON via JAXB
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
@Schema(allOf = { TransactionData.class })
|
||||
@XmlDiscriminatorValue("CREATE_POLL")
|
||||
public class CreatePollTransactionData extends TransactionData {
|
||||
|
||||
|
||||
@Schema(description = "Poll creator's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP")
|
||||
private byte[] pollCreatorPublicKey;
|
||||
|
||||
// Properties
|
||||
private String owner;
|
||||
private String pollName;
|
||||
@@ -29,10 +36,15 @@ public class CreatePollTransactionData extends TransactionData {
|
||||
super(TransactionType.CREATE_POLL);
|
||||
}
|
||||
|
||||
public void afterUnmarshal(Unmarshaller u, Object parent) {
|
||||
this.creatorPublicKey = this.pollCreatorPublicKey;
|
||||
}
|
||||
|
||||
public CreatePollTransactionData(BaseTransactionData baseTransactionData,
|
||||
String owner, String pollName, String description, List<PollOptionData> pollOptions) {
|
||||
super(Transaction.TransactionType.CREATE_POLL, baseTransactionData);
|
||||
|
||||
this.creatorPublicKey = baseTransactionData.creatorPublicKey;
|
||||
this.owner = owner;
|
||||
this.pollName = pollName;
|
||||
this.description = description;
|
||||
@@ -41,6 +53,7 @@ public class CreatePollTransactionData extends TransactionData {
|
||||
|
||||
// Getters/setters
|
||||
|
||||
public byte[] getPollCreatorPublicKey() { return this.creatorPublicKey; }
|
||||
public String getOwner() {
|
||||
return this.owner;
|
||||
}
|
||||
|
@@ -12,6 +12,7 @@ import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||
|
||||
import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorNode;
|
||||
import org.qortal.crypto.Crypto;
|
||||
import org.qortal.data.voting.PollData;
|
||||
import org.qortal.transaction.Transaction.ApprovalStatus;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
|
||||
@@ -29,6 +30,7 @@ import io.swagger.v3.oas.annotations.media.Schema.AccessMode;
|
||||
@XmlSeeAlso({GenesisTransactionData.class, PaymentTransactionData.class, RegisterNameTransactionData.class, UpdateNameTransactionData.class,
|
||||
SellNameTransactionData.class, CancelSellNameTransactionData.class, BuyNameTransactionData.class,
|
||||
CreatePollTransactionData.class, VoteOnPollTransactionData.class, ArbitraryTransactionData.class,
|
||||
PollData.class,
|
||||
IssueAssetTransactionData.class, TransferAssetTransactionData.class,
|
||||
CreateAssetOrderTransactionData.class, CancelAssetOrderTransactionData.class,
|
||||
MultiPaymentTransactionData.class, DeployAtTransactionData.class, MessageTransactionData.class, ATTransactionData.class,
|
||||
|
@@ -3,7 +3,9 @@ package org.qortal.data.transaction;
|
||||
import javax.xml.bind.Unmarshaller;
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.XmlTransient;
|
||||
|
||||
import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorValue;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
@@ -11,12 +13,17 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
// All properties to be converted to JSON via JAXB
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
@Schema(allOf = { TransactionData.class })
|
||||
@XmlDiscriminatorValue("VOTE_ON_POLL")
|
||||
public class VoteOnPollTransactionData extends TransactionData {
|
||||
|
||||
// Properties
|
||||
@Schema(description = "Vote creator's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP")
|
||||
private byte[] voterPublicKey;
|
||||
private String pollName;
|
||||
private int optionIndex;
|
||||
// For internal use when orphaning
|
||||
@XmlTransient
|
||||
@Schema(hidden = true)
|
||||
private Integer previousOptionIndex;
|
||||
|
||||
// Constructors
|
||||
|
@@ -14,6 +14,11 @@ public class PollData {
|
||||
|
||||
// Constructors
|
||||
|
||||
// For JAXB
|
||||
protected PollData() {
|
||||
super();
|
||||
}
|
||||
|
||||
public PollData(byte[] creatorPublicKey, String owner, String pollName, String description, List<PollOptionData> pollOptions, long published) {
|
||||
this.creatorPublicKey = creatorPublicKey;
|
||||
this.owner = owner;
|
||||
@@ -29,22 +34,42 @@ public class PollData {
|
||||
return this.creatorPublicKey;
|
||||
}
|
||||
|
||||
public void setCreatorPublicKey(byte[] creatorPublicKey) {
|
||||
this.creatorPublicKey = creatorPublicKey;
|
||||
}
|
||||
|
||||
public String getOwner() {
|
||||
return this.owner;
|
||||
}
|
||||
|
||||
public void setOwner(String owner) {
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
public String getPollName() {
|
||||
return this.pollName;
|
||||
}
|
||||
|
||||
public void setPollName(String pollName) {
|
||||
this.pollName = pollName;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return this.description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public List<PollOptionData> getPollOptions() {
|
||||
return this.pollOptions;
|
||||
}
|
||||
|
||||
public void setPollOptions(List<PollOptionData> pollOptions) {
|
||||
this.pollOptions = pollOptions;
|
||||
}
|
||||
|
||||
public long getPublished() {
|
||||
return this.published;
|
||||
}
|
||||
|
@@ -9,6 +9,7 @@ import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -52,6 +53,15 @@ public class ResourceList {
|
||||
String jsonString = ResourceList.listToJSONString(this.list);
|
||||
Path filePath = this.getFilePath();
|
||||
|
||||
// Don't create list if it's empty
|
||||
if (this.list != null && this.list.isEmpty()) {
|
||||
if (filePath != null && Files.exists(filePath)) {
|
||||
// Delete empty list
|
||||
Files.delete(filePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create parent directory if needed
|
||||
try {
|
||||
Files.createDirectories(filePath.getParent());
|
||||
@@ -72,7 +82,7 @@ public class ResourceList {
|
||||
}
|
||||
|
||||
try {
|
||||
String jsonString = new String(Files.readAllBytes(path));
|
||||
String jsonString = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
|
||||
this.list = ResourceList.listFromJSONString(jsonString);
|
||||
} catch (IOException e) {
|
||||
throw new IOException(String.format("Couldn't read contents from file %s", path.toString()));
|
||||
@@ -109,6 +119,13 @@ public class ResourceList {
|
||||
this.list.remove(resource);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
if (this.list == null) {
|
||||
return;
|
||||
}
|
||||
this.list.clear();
|
||||
}
|
||||
|
||||
public boolean contains(String resource, boolean caseSensitive) {
|
||||
if (resource == null || this.list == null) {
|
||||
return false;
|
||||
|
@@ -2,8 +2,11 @@ package org.qortal.list;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.qortal.settings.Settings;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -18,6 +21,7 @@ public class ResourceListManager {
|
||||
|
||||
|
||||
public ResourceListManager() {
|
||||
this.lists = this.fetchLists();
|
||||
}
|
||||
|
||||
public static synchronized ResourceListManager getInstance() {
|
||||
@@ -27,6 +31,38 @@ public class ResourceListManager {
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static synchronized void reset() {
|
||||
if (instance != null) {
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<ResourceList> fetchLists() {
|
||||
List<ResourceList> lists = new ArrayList<>();
|
||||
Path listsPath = Paths.get(Settings.getInstance().getListsPath());
|
||||
|
||||
if (listsPath.toFile().isDirectory()) {
|
||||
String[] files = listsPath.toFile().list();
|
||||
|
||||
for (String fileName : files) {
|
||||
try {
|
||||
// Remove .json extension
|
||||
if (fileName.endsWith(".json")) {
|
||||
fileName = fileName.substring(0, fileName.length() - 5);
|
||||
}
|
||||
|
||||
ResourceList list = new ResourceList(fileName);
|
||||
if (list != null) {
|
||||
lists.add(list);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Ignore this list
|
||||
}
|
||||
}
|
||||
}
|
||||
return lists;
|
||||
}
|
||||
|
||||
private ResourceList getList(String listName) {
|
||||
for (ResourceList list : this.lists) {
|
||||
if (Objects.equals(list.getName(), listName)) {
|
||||
@@ -48,6 +84,18 @@ public class ResourceListManager {
|
||||
|
||||
}
|
||||
|
||||
private List<ResourceList> getListsByPrefix(String listNamePrefix) {
|
||||
List<ResourceList> lists = new ArrayList<>();
|
||||
|
||||
for (ResourceList list : this.lists) {
|
||||
if (list != null && list.getName() != null && list.getName().startsWith(listNamePrefix)) {
|
||||
lists.add(list);
|
||||
}
|
||||
}
|
||||
|
||||
return lists;
|
||||
}
|
||||
|
||||
public boolean addToList(String listName, String item, boolean save) {
|
||||
ResourceList list = this.getList(listName);
|
||||
if (list == null) {
|
||||
@@ -95,6 +143,16 @@ public class ResourceListManager {
|
||||
return list.contains(item, caseSensitive);
|
||||
}
|
||||
|
||||
public boolean listWithPrefixContains(String listNamePrefix, String item, boolean caseSensitive) {
|
||||
List<ResourceList> lists = getListsByPrefix(listNamePrefix);
|
||||
for (ResourceList list : lists) {
|
||||
if (list.contains(item, caseSensitive)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void saveList(String listName) {
|
||||
ResourceList list = this.getList(listName);
|
||||
if (list == null) {
|
||||
@@ -133,6 +191,15 @@ public class ResourceListManager {
|
||||
return list.getList();
|
||||
}
|
||||
|
||||
public List<String> getStringsInListsWithPrefix(String listNamePrefix) {
|
||||
List<String> items = new ArrayList<>();
|
||||
List<ResourceList> lists = getListsByPrefix(listNamePrefix);
|
||||
for (ResourceList list : lists) {
|
||||
items.addAll(list.getList());
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
public int getItemCountForList(String listName) {
|
||||
ResourceList list = this.getList(listName);
|
||||
if (list == null) {
|
||||
|
@@ -16,6 +16,8 @@ import org.qortal.repository.Repository;
|
||||
import org.qortal.transaction.Transaction.TransactionType;
|
||||
import org.qortal.utils.Unicode;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class Name {
|
||||
|
||||
// Properties
|
||||
@@ -116,7 +118,7 @@ public class Name {
|
||||
|
||||
this.repository.getNameRepository().save(this.nameData);
|
||||
|
||||
if (!updateNameTransactionData.getNewName().isEmpty())
|
||||
if (!updateNameTransactionData.getNewName().isEmpty() && !Objects.equals(updateNameTransactionData.getName(), updateNameTransactionData.getNewName()))
|
||||
// Name has changed, delete old entry
|
||||
this.repository.getNameRepository().delete(updateNameTransactionData.getNewName());
|
||||
|
||||
@@ -180,8 +182,12 @@ public class Name {
|
||||
}
|
||||
|
||||
public void cancelSell(CancelSellNameTransactionData cancelSellNameTransactionData) throws DataException {
|
||||
// Mark not for-sale but leave price in case we want to orphan
|
||||
// Update previous sale price in transaction data
|
||||
cancelSellNameTransactionData.setSalePrice(this.nameData.getSalePrice());
|
||||
|
||||
// Mark not for-sale
|
||||
this.nameData.setIsForSale(false);
|
||||
this.nameData.setSalePrice(null);
|
||||
|
||||
// Save sale info into repository
|
||||
this.repository.getNameRepository().save(this.nameData);
|
||||
@@ -190,6 +196,7 @@ public class Name {
|
||||
public void uncancelSell(CancelSellNameTransactionData cancelSellNameTransactionData) throws DataException {
|
||||
// Mark as for-sale using existing price
|
||||
this.nameData.setIsForSale(true);
|
||||
this.nameData.setSalePrice(cancelSellNameTransactionData.getSalePrice());
|
||||
|
||||
// Save no-sale info into repository
|
||||
this.repository.getNameRepository().save(this.nameData);
|
||||
|
@@ -265,7 +265,7 @@ public enum Handshake {
|
||||
private static final long PEER_VERSION_131 = 0x0100030001L;
|
||||
|
||||
/** Minimum peer version that we are allowed to communicate with */
|
||||
private static final String MIN_PEER_VERSION = "3.7.0";
|
||||
private static final String MIN_PEER_VERSION = "3.8.2";
|
||||
|
||||
private static final int POW_BUFFER_SIZE_PRE_131 = 8 * 1024 * 1024; // bytes
|
||||
private static final int POW_DIFFICULTY_PRE_131 = 8; // leading zero bits
|
||||
|
@@ -124,6 +124,8 @@ public class Network {
|
||||
|
||||
private final List<PeerAddress> selfPeers = new ArrayList<>();
|
||||
|
||||
private String bindAddress = null;
|
||||
|
||||
private final ExecuteProduceConsume networkEPC;
|
||||
private Selector channelSelector;
|
||||
private ServerSocketChannel serverChannel;
|
||||
@@ -159,25 +161,43 @@ public class Network {
|
||||
// Grab P2P port from settings
|
||||
int listenPort = Settings.getInstance().getListenPort();
|
||||
|
||||
// Grab P2P bind address from settings
|
||||
try {
|
||||
InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress());
|
||||
InetSocketAddress endpoint = new InetSocketAddress(bindAddr, listenPort);
|
||||
// Grab P2P bind addresses from settings
|
||||
List<String> bindAddresses = new ArrayList<>();
|
||||
if (Settings.getInstance().getBindAddress() != null) {
|
||||
bindAddresses.add(Settings.getInstance().getBindAddress());
|
||||
}
|
||||
if (Settings.getInstance().getBindAddressFallback() != null) {
|
||||
bindAddresses.add(Settings.getInstance().getBindAddressFallback());
|
||||
}
|
||||
|
||||
channelSelector = Selector.open();
|
||||
for (int i=0; i<bindAddresses.size(); i++) {
|
||||
try {
|
||||
String bindAddress = bindAddresses.get(i);
|
||||
InetAddress bindAddr = InetAddress.getByName(bindAddress);
|
||||
InetSocketAddress endpoint = new InetSocketAddress(bindAddr, listenPort);
|
||||
|
||||
// Set up listen socket
|
||||
serverChannel = ServerSocketChannel.open();
|
||||
serverChannel.configureBlocking(false);
|
||||
serverChannel.setOption(StandardSocketOptions.SO_REUSEADDR, true);
|
||||
serverChannel.bind(endpoint, LISTEN_BACKLOG);
|
||||
serverSelectionKey = serverChannel.register(channelSelector, SelectionKey.OP_ACCEPT);
|
||||
} catch (UnknownHostException e) {
|
||||
LOGGER.error("Can't bind listen socket to address {}", Settings.getInstance().getBindAddress());
|
||||
throw new IOException("Can't bind listen socket to address", e);
|
||||
} catch (IOException e) {
|
||||
LOGGER.error("Can't create listen socket: {}", e.getMessage());
|
||||
throw new IOException("Can't create listen socket", e);
|
||||
channelSelector = Selector.open();
|
||||
|
||||
// Set up listen socket
|
||||
serverChannel = ServerSocketChannel.open();
|
||||
serverChannel.configureBlocking(false);
|
||||
serverChannel.setOption(StandardSocketOptions.SO_REUSEADDR, true);
|
||||
serverChannel.bind(endpoint, LISTEN_BACKLOG);
|
||||
serverSelectionKey = serverChannel.register(channelSelector, SelectionKey.OP_ACCEPT);
|
||||
|
||||
this.bindAddress = bindAddress; // Store the selected address, so that it can be used by other parts of the app
|
||||
break; // We don't want to bind to more than one address
|
||||
} catch (UnknownHostException e) {
|
||||
LOGGER.error("Can't bind listen socket to address {}", Settings.getInstance().getBindAddress());
|
||||
if (i == bindAddresses.size()-1) { // Only throw an exception if all addresses have been tried
|
||||
throw new IOException("Can't bind listen socket to address", e);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.error("Can't create listen socket: {}", e.getMessage());
|
||||
if (i == bindAddresses.size()-1) { // Only throw an exception if all addresses have been tried
|
||||
throw new IOException("Can't create listen socket", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load all known peers from repository
|
||||
@@ -228,6 +248,10 @@ public class Network {
|
||||
return this.maxPeers;
|
||||
}
|
||||
|
||||
public String getBindAddress() {
|
||||
return this.bindAddress;
|
||||
}
|
||||
|
||||
public byte[] getMessageMagic() {
|
||||
return Settings.getInstance().isTestNet() ? TESTNET_MESSAGE_MAGIC : MAINNET_MESSAGE_MAGIC;
|
||||
}
|
||||
@@ -339,7 +363,7 @@ public class Network {
|
||||
try {
|
||||
if (!isConnected) {
|
||||
// Add this signature to the list of pending requests for this peer
|
||||
LOGGER.info("Making connection to peer {} to request files for signature {}...", peerAddressString, Base58.encode(signature));
|
||||
LOGGER.debug("Making connection to peer {} to request files for signature {}...", peerAddressString, Base58.encode(signature));
|
||||
Peer peer = new Peer(peerData);
|
||||
peer.setIsDataPeer(true);
|
||||
peer.addPendingSignatureRequest(signature);
|
||||
@@ -1556,7 +1580,7 @@ public class Network {
|
||||
this.isShuttingDown = true;
|
||||
|
||||
// Close listen socket to prevent more incoming connections
|
||||
if (this.serverChannel.isOpen()) {
|
||||
if (this.serverChannel != null && this.serverChannel.isOpen()) {
|
||||
try {
|
||||
this.serverChannel.close();
|
||||
} catch (IOException e) {
|
||||
|
@@ -155,6 +155,11 @@ public class Peer {
|
||||
*/
|
||||
private CommonBlockData commonBlockData;
|
||||
|
||||
/**
|
||||
* Last time we detected this peer as TOO_DIVERGENT
|
||||
*/
|
||||
private Long lastTooDivergentTime;
|
||||
|
||||
// Message stats
|
||||
|
||||
private static class MessageStats {
|
||||
@@ -383,6 +388,14 @@ public class Peer {
|
||||
this.commonBlockData = commonBlockData;
|
||||
}
|
||||
|
||||
public Long getLastTooDivergentTime() {
|
||||
return this.lastTooDivergentTime;
|
||||
}
|
||||
|
||||
public void setLastTooDivergentTime(Long lastTooDivergentTime) {
|
||||
this.lastTooDivergentTime = lastTooDivergentTime;
|
||||
}
|
||||
|
||||
public boolean isSyncInProgress() {
|
||||
return this.syncInProgress;
|
||||
}
|
||||
|
@@ -68,7 +68,7 @@ public class ArbitraryDataFileMessage extends Message {
|
||||
byteBuffer.get(data);
|
||||
|
||||
try {
|
||||
ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(data, signature);
|
||||
ArbitraryDataFile arbitraryDataFile = new ArbitraryDataFile(data, signature, false);
|
||||
return new ArbitraryDataFileMessage(id, signature, arbitraryDataFile);
|
||||
} catch (DataException e) {
|
||||
LOGGER.info("Unable to process received file: {}", e.getMessage());
|
||||
|
@@ -64,7 +64,7 @@ public class ArbitraryMetadataMessage extends Message {
|
||||
byteBuffer.get(data);
|
||||
|
||||
try {
|
||||
ArbitraryDataFile arbitraryMetadataFile = new ArbitraryDataFile(data, signature);
|
||||
ArbitraryDataFile arbitraryMetadataFile = new ArbitraryDataFile(data, signature, false);
|
||||
return new ArbitraryMetadataMessage(id, signature, arbitraryMetadataFile);
|
||||
} catch (DataException e) {
|
||||
throw new MessageException("Unable to process arbitrary metadata message: " + e.getMessage(), e);
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user