Compare commits

..

221 Commits

Author SHA1 Message Date
CalDescent
133848ef50 Speed up status rebuilding by excluding transactions that aren't hosted locally by the node. 2023-08-05 19:42:30 +01:00
CalDescent
e44b38819e Specify table name in query, to avoid potential ambiguity with indexes. 2023-08-05 13:17:30 +01:00
CalDescent
eecd37d6bc Speed up finding arbitrary transactions. 2023-08-05 13:10:03 +01:00
CalDescent
a3ab5238d3 Merge branch 'master' into arbitrary-resources-cache 2023-08-05 13:01:00 +01:00
CalDescent
9574100a08 Bump version to 4.2.2 2023-08-02 21:36:57 +01:00
CalDescent
528583fe38 Added logging relating to unconfirmed transactions. 2023-08-02 21:32:57 +01:00
CalDescent
33cfd02c49 Fixed issues in commit f5c8dfe 2023-08-02 21:13:33 +01:00
CalDescent
94d3664cb0 Bump version to 4.2.1 2023-07-31 19:30:45 +01:00
CalDescent
f5c8dfe766 Added maxTransactionsPerBlock setting (default 25) to reduce minting load on slower machines.
This is a short term limit, is well above current usage levels, and can be increased substantially in future once the block minter code has been improved.
2023-07-31 19:25:26 +01:00
CalDescent
f7e1f2fca8 Increased timeout for SEARCH_QDN_RESOURCES from 10 to 30 seconds. 2023-07-28 21:47:29 +01:00
CalDescent
811b647c88 Catch UnsupportedAddressTypeException and fall back to IPv4 binding. 2023-07-28 18:58:47 +01:00
CalDescent
3215bb638d More online accounts improvements 2023-07-22 10:44:41 +01:00
CalDescent
8ae7a1d65b Removed (Get)OnlineAccountsV1 and V2, as these are no longer used. 2023-07-21 14:28:47 +01:00
CalDescent
29dcd53002 Revert "Improved filtering of online accounts data."
This reverts commit c14fca5660.
2023-07-16 20:04:45 +01:00
CalDescent
62908f867a Bump version to 4.2.0 2023-07-16 19:09:08 +01:00
CalDescent
5f86ecafd9 Refactored developer proxy, and modified IPv6 fallback so that it only occurs on a connection failure. 2023-07-09 12:35:46 +01:00
CalDescent
9694094bbf Sanitize inputs used for the working path when building arbitrary data, and throw/handle an exception if it still doesn't work.
Should fix issue on Windows systems due to reserved characters in certain resource names.
2023-07-08 14:27:24 +01:00
CalDescent
d8237abde5 Don't update statuses when processing arbitrary transactions, to improve success rate and speed it up. 2023-07-08 14:16:50 +01:00
CalDescent
fe999a11f4 Include CANCEL_SELL_NAME transactions when performing a complete rebuild of names. 2023-07-08 11:06:33 +01:00
CalDescent
c14fca5660 Improved filtering of online accounts data. 2023-07-08 11:05:14 +01:00
CalDescent
537779b152 Use a separate repository instance when updating caches. 2023-07-02 17:49:49 +01:00
CalDescent
fd8d720946 Added support for group encryption in service validation. 2023-06-23 13:32:23 +01:00
CalDescent
c0eeef546a Added support for group encryption in service validation. 2023-06-23 13:30:10 +01:00
CalDescent
badd6ad2b0 Added optional minLevel filter to GET /arbitrary/resources/search and the SEARCH_QDN_RESOURCES action. 2023-06-23 11:55:49 +01:00
CalDescent
b4794ada72 Merge branch 'master' into arbitrary-resources-cache 2023-06-17 14:17:49 +01:00
CalDescent
d628b3ab2a Renamed the "usePrefix" parameter to "includeResourceIdInPrefix", and slightly modified its functionality. 2023-06-17 13:04:46 +01:00
CalDescent
5928b54a33 Added developer QDN proxy.
This allows Q-Apps and websites to be developed and tested in real time, by proxying an existing webserver such as localhost:5173 from React/Vite. The proxy adds all QDN functionality to the existing server in real time. Needs UI integration before all features can be used.
2023-06-17 13:03:29 +01:00
CalDescent
4b04b99401 Discard changes before setting status. 2023-06-16 14:59:07 +01:00
CalDescent
7e872f7800 Update QDN cache when receiving a metadata file as part of a resource download. 2023-06-16 14:58:33 +01:00
QuickMythril
91dfc5efd0 Merge pull request #119 from QuickMythril/upgrade-tls
Upgraded to TLSv1.3
2023-06-14 10:25:25 -04:00
QuickMythril
1343a88ee3 Merge pull request #124 from QuickMythril/translate-jp
Added Japanese translations (Credit: R M)
2023-06-02 08:01:04 -04:00
QuickMythril
7f7b02f003 Updated Japanese translations (Credit: R M) 2023-06-02 06:17:48 -04:00
QuickMythril
5650923805 Merge pull request #123 from QuickMythril/2023-05-25
Added some fee info & Q-App support for foreign coins
2023-05-31 09:14:46 -04:00
CalDescent
5fb2640a3a Bump version to 4.1.3 2023-05-30 18:05:05 +01:00
CalDescent
66c91fd365 MIN_PEER_VERSION for handshake set to 4.1.1 2023-05-30 17:42:31 +01:00
CalDescent
bfc03db6a9 Default minPeerVersion set to 4.1.2 2023-05-30 17:41:39 +01:00
QuickMythril
a4bb445f3e Added Japanese translations for review 2023-05-29 04:23:22 -04:00
QuickMythril
27afcf12bf Prepared files for Japanese translations 2023-05-29 04:07:52 -04:00
CalDescent
707176a202 Improved detection of an existing arbitrary resources cache. 2023-05-27 11:30:19 +02:00
CalDescent
74a914367f Merge branch 'master' into arbitrary-resources-cache
# Conflicts:
#	src/main/java/org/qortal/controller/Controller.java
#	src/main/java/org/qortal/repository/RepositoryManager.java
#	src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java
2023-05-26 19:37:13 +02:00
CalDescent
eda6ab5701 Fixed some failing unit tests, and ignored some failing BTC ones that have been superseded by LTC. 2023-05-26 18:01:09 +02:00
QuickMythril
13da0e8a7a Adjusted fee info to long format 2023-05-25 17:26:29 -04:00
QuickMythril
d260c0a9a9 Updated info on foreign coin fees 2023-05-25 16:35:08 -04:00
QuickMythril
655073c524 Added 2m timeout for GET_WALLET_BALANCE action 2023-05-25 04:41:03 -04:00
crowetic
c8f3b6918f Update start.sh
Added better defaults for JVM_MEMORY_ARGS and description as to how and when to uncomment the line.
2023-05-24 16:20:05 -07:00
CalDescent
1565a461ac Bump version to 4.1.2 2023-05-24 20:01:18 +01:00
CalDescent
1f30bef4f8 defaultArchiveVersion set to 2 2023-05-24 19:54:36 +01:00
CalDescent
6f0479c4fc Default minPeerVersion set to 4.1.1 2023-05-24 19:47:37 +01:00
CalDescent
b967800a3e Default repositoryConnectionPoolSize set to 240 2023-05-24 19:47:25 +01:00
CalDescent
0b50f965cc Default maxNetworkThreadPoolSize set to 120 2023-05-24 19:47:10 +01:00
CalDescent
90f7cee058 Bump version to 4.1.1 2023-05-21 20:34:04 +01:00
CalDescent
947b523e61 Limit query to 100 so that it doesn't return endless amounts of transaction signatures.
Using a separate database method for now to reduce risk of interfering with other parts of the code which use it. It can be combined later when there is more testing time.
2023-05-21 20:33:33 +01:00
CalDescent
95d72866e9 Use a better method to detect if a transactions table in need of a rebuild.
Should handle cases where a previous rebuild didn't fully complete, or missed a block.
2023-05-21 20:06:09 +01:00
CalDescent
aea1cc62c8 Fixed off-by-one bug (correctly this time) 2023-05-21 20:02:58 +01:00
CalDescent
c763445e6e Log to console if an extra core restart is needed to complete the update process (this needed ins some cases after bootstrapping). 2023-05-21 19:51:22 +01:00
CalDescent
7a6b83aa22 Bump version to 4.1.0 2023-05-21 16:49:59 +01:00
CalDescent
ba555174ba Merge branch 'master' into block-sequence 2023-05-21 15:35:50 +01:00
CalDescent
3763035d4a Default recoveryModeTimeout increased to 24 hours for now.
It doesn't quite work as intended, so it's best that it doesn't interfere right away. 24 hours should be long enough for any issues to be manually resolved.
2023-05-21 15:34:27 +01:00
CalDescent
b1a904a3c7 MIN_PEER_VERSION set to 4.0.0 2023-05-21 15:26:49 +01:00
CalDescent
3c4c5a1457 Default minPeerVersion set to 4.0.0 2023-05-21 15:22:24 +01:00
CalDescent
648fa66f6a Increased default maxPeers to 40. 2023-05-21 15:22:00 +01:00
CalDescent
072aa469e3 Reduce default minBlockchainPeers to 3, ahead of the upcoming reshape. 2023-05-21 15:21:04 +01:00
CalDescent
2b2d6f4e52 Updated message. 2023-05-21 14:02:45 +01:00
CalDescent
c6456669e2 Don't allow core to start if transaction sequences haven't been rebuilt yet. 2023-05-21 12:33:37 +01:00
CalDescent
a74fa15d60 Missing import 2023-05-21 12:31:49 +01:00
CalDescent
68b99c8643 Update status when rebuilding transaction sequences. 2023-05-21 12:28:51 +01:00
CalDescent
b9015217de Fixed bug causing final block to be missed in the reshape. 2023-05-21 11:05:34 +01:00
CalDescent
e1043ceacb Fixed bug causing duplicate AT entries in local array. 2023-05-21 08:41:56 +01:00
CalDescent
8b51590844 Include AT transactions when rebuilding transaction sequences, as these aren't directly included in the block archive. 2023-05-20 20:54:22 +01:00
CalDescent
a8d92805f9 Added extra check for topOnly mode. 2023-05-20 11:33:43 +01:00
CalDescent
2cc5b90306 Merge branch 'master' into block-sequence 2023-05-14 17:28:27 +01:00
CalDescent
f5f82dc3f6 Fixed issues relating to using a separate repository instance when determining the latest status of a resource. 2023-05-13 19:20:18 +01:00
CalDescent
633f73aa86 Removed API key fields from documentation for methods that don't require an API key by default. 2023-05-13 16:32:33 +01:00
CalDescent
a49529ad9b Cache updating moved back to existing threads when processing or importing a transaction, to remove chances of queued updates being lost.
The dedicated cache manager thread is now used for metadata updates only. If metadata ever goes missing from the db, it would be straightforward to have a background thread that corrects any discrepancies between the filesystem and the db. Not adding that until it is needed.
2023-05-13 15:11:32 +01:00
CalDescent
f451bccbf6 Fixed bug causing descriptions to be truncated in the cache. 2023-05-13 14:54:00 +01:00
CalDescent
4cb755a2f1 Added GET /stats/supply/circulating API endpoint, to fetch total QORT minted so far. 2023-05-13 13:48:27 +01:00
CalDescent
5ed3237d2f Clear queue before exiting cache manager thread. 2023-05-13 13:36:40 +01:00
CalDescent
5c7d12f25e Fixed bug causing incorrect creation dates in the cache. 2023-05-13 12:27:56 +01:00
CalDescent
92119b5558 Increased per-name limit for followed names by 4x. 2023-05-12 20:14:14 +01:00
CalDescent
23d211836f Fixed case sensitivity issue when updating status in the cache. 2023-05-12 20:10:51 +01:00
CalDescent
36a731255a Automatically delete cached resources & metadata if there is no longer a latest transaction. 2023-05-12 20:08:53 +01:00
CalDescent
b661d39844 Cache updating moved to a dedicated thread.
Hopeful fix for serialization failures which occurred when updating from various different network threads.
2023-05-12 19:39:31 +01:00
CalDescent
7725c5e21f Always ignore unsupported services when building the cache. 2023-05-12 12:03:32 +01:00
CalDescent
21f01226e9 Merge branch 'master' into arbitrary-resources-cache 2023-05-12 11:50:24 +01:00
CalDescent
8a1bf8b5ec Return full name data in GET /names. 2023-05-12 11:41:15 +01:00
CalDescent
f8233bd05b Added optional after parameter to GET /names. 2023-05-12 11:41:00 +01:00
CalDescent
29480e5664 Added SEARCH_NAMES Q-App action. 2023-05-12 11:17:09 +01:00
CalDescent
5a873f9465 Added prefix parameter to GET /names/search. 2023-05-12 11:11:34 +01:00
CalDescent
dc1289787d Ignore per-name limits when using storagePolicy ALL. 2023-05-12 10:12:38 +01:00
CalDescent
ba4866a2e6 Added GET /crosschain/tradeoffers/hidden endpoint, to show offers that are currently being hidden.
This uses the maxTradeOfferAttempts setting, so modifying this setting will affect the number of offers that are returned.
2023-05-12 10:01:38 +01:00
CalDescent
2cbc5aabd5 Added maxTradeOfferAttempts setting (default 3).
Offers with more than 3 failures will be hidden from the API and websocket, to prevent unbuyable offers from staying in the order books and continuously failing. maxTradeOfferAttempts can be optionally increased on a node to show more trades that would otherwise be hidden.
2023-05-12 09:59:30 +01:00
QuickMythril
e3be43a1e6 Changed get name API call to use reduced name 2023-05-11 12:31:00 -04:00
QuickMythril
1e10bcf3b0 Merge branch 'Qortal:master' into upgrade-tls 2023-05-09 15:38:20 -04:00
QuickMythril
a575ea4423 Merge pull request #120 from QuickMythril/get-votes-api
Created get votes API call
2023-05-09 15:34:54 -04:00
QuickMythril
3e45948646 Added get votes option to return only counts 2023-05-08 23:41:31 -04:00
QuickMythril
49c0d45bc6 Added count to get votes API call 2023-05-08 23:26:23 -04:00
QuickMythril
cda32a47f1 Added API call to get votes 2023-05-08 20:23:54 -04:00
CalDescent
49063e54ec Bump version to 4.0.3 2023-05-08 19:18:38 +01:00
CalDescent
df3c68679f Log the action to the console, instead of the entire event. 2023-05-08 14:43:00 +01:00
CalDescent
c210d63c40 Added "mode" parameter to GET /arbitrary/resources/search, with possible values of LATEST, ALL.
By default, only the latest resource is returned for a name/service combination. All identifiers can be optionally returned by setting `mode` to "ALL".

More search modes can be added in the future, for instance "RELEVANT" or "POPULAR" (these are just ideas, and are not currently supported).
2023-05-08 13:41:23 +01:00
CalDescent
0ec661431c Added optional "before" and "after" params to GET /arbitrary/resources/search 2023-05-08 12:46:15 +01:00
CalDescent
8fa344125c Fixed issue updating cache when receiving metadata via the network. 2023-05-08 12:34:26 +01:00
CalDescent
2fd5bfb11a Support title/description metadata searching in GET /arbitrary/resources/search
"query" searches name, identifier, title and description fields
"title" searches title only
"description" searches description only

All support "&prefix=true", to indicate searching by prefix only.
2023-05-08 12:34:26 +01:00
CalDescent
cdcb268bd9 Exclude status if includeStatus != true 2023-05-08 12:34:26 +01:00
CalDescent
d03a2d7da9 Resource statuses moved to the db, so they don't have to be calculated on demand for every API call. 2023-05-08 12:34:26 +01:00
CalDescent
961aa9eefd Show splash screen when building QDN cache. 2023-05-08 12:34:26 +01:00
CalDescent
865d3d8aff Fixed ordering, to keep consistency with existing approach. 2023-05-08 12:34:26 +01:00
CalDescent
c0f29f848f Fixed more bugs. 2023-05-08 12:34:26 +01:00
CalDescent
94f4c501fa Update caches where possible when processing arbitrary transactions. 2023-05-08 12:34:26 +01:00
CalDescent
200b0f3412 Added POST /arbitrary/resources/cache/rebuild endpoint to allow a rebuild of the cache. 2023-05-08 12:34:25 +01:00
CalDescent
eb7a29dd2e Fixed bugs. 2023-05-08 12:34:25 +01:00
CalDescent
9dba4b2968 Initial attempt at a database cache to hold arbitrary resources and metadata. 2023-05-08 12:34:25 +01:00
CalDescent
81788610c4 Merge branch 'master' of github.com:Qortal/qortal 2023-05-08 12:18:34 +01:00
CalDescent
fc10b61193 Fixed slow validation issue caused by loading the entire resource into memory. 2023-05-08 12:17:44 +01:00
CalDescent
05b4ecd4ed Updated documentation. 2023-05-08 12:16:17 +01:00
CalDescent
aba589c0e0 Added optional "build" parameter to GET_QDN_RESOURCE_STATUS.
This triggers an async build when checking the status.
2023-05-08 12:15:53 +01:00
CalDescent
c682fa89fd Avoid duplicate concurrent QDN builds. 2023-05-08 12:14:00 +01:00
CalDescent
21d1750779 Added more debug logging when building resources. 2023-05-08 12:13:12 +01:00
CalDescent
923e90ebed Fixed occasional NPE 2023-05-08 12:12:40 +01:00
catbref
9490c62242 Improved tx.pl that supports local signing via openssl and "deploy_at" transaction type + other minor fixes 2023-05-08 12:07:02 +01:00
CalDescent
c941bc6024 Catch and log all exceptions when publishing data. 2023-05-07 11:19:42 +01:00
CalDescent
0acf0729e9 Bump version to 4.0.2 2023-05-06 15:10:46 +01:00
CalDescent
1f77ee535f Added link to example Q-App projects. 2023-05-06 12:16:59 +01:00
CalDescent
b693a514fd Fixed warnings, and other improvements. 2023-05-06 12:13:41 +01:00
CalDescent
b571931127 Fixed formatting of services list 2023-05-05 22:35:19 +01:00
CalDescent
92b983a16e Q-Apps documentation updates. 2023-05-05 22:25:12 +01:00
CalDescent
3f71a63512 Increased timeout for other new actions. 2023-05-05 18:30:14 +01:00
CalDescent
86b5bae320 Set timeout of PUBLISH_MULTIPLE_QDN_RESOURCES to 60 mins. 2023-05-05 13:22:14 +01:00
CalDescent
3775135e0c Added helper methods to fetch lists of private or public service objects.
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 the node is part of a data market contract for that data - this isn't developed yet).
2023-05-05 12:39:11 +01:00
CalDescent
c172a5764b Added _PRIVATE services, to allow for publishing/validation of encrypted data.
New additions:

QCHAT_ATTACHMENT_PRIVATE
ATTACHMENT_PRIVATE
FILE_PRIVATE
IMAGE_PRIVATE
VIDEO_PRIVATE
AUDIO_PRIVATE
VOICE_PRIVATE
DOCUMENT_PRIVATE
MAIL_PRIVATE
MESSAGE_PRIVATE
2023-05-05 12:26:18 +01:00
CalDescent
1a5e3b4fb1 Added GET /names/search endpoint, to search names via case insensitive, partial name matching. 2023-05-05 11:24:52 +01:00
CalDescent
f39b6a15da Fixed refresh bug on Windows. 2023-05-05 11:03:13 +01:00
CalDescent
2dfee13d86 Remove all backslashes from vars in HTML parser (correct order this time) 2023-05-03 19:44:54 +01:00
CalDescent
b9d81645f8 Revert "Remove all backslashes from vars in HTML parser."
This reverts commit 9547a087b2.
2023-05-03 19:40:17 +01:00
CalDescent
9547a087b2 Remove all backslashes from vars in HTML parser. 2023-05-03 19:38:31 +01:00
CalDescent
e014a207ef Escape all vars added by HTML parser 2023-05-03 19:28:26 +01:00
CalDescent
611240650e Added GET /chat/messages/count endpoint, which is identical to /chat/messages but returns a count of the messages rather than the messages themselves. 2023-05-03 19:27:59 +01:00
CalDescent
c71dce92b5 Bump version to 4.0.1 2023-05-01 19:34:01 +01:00
CalDescent
34c3adf280 Limit MAIL and MESSAGE to 1MB. 2023-04-29 19:04:17 +01:00
CalDescent
95a1c6bf8b Added "encoding" parameter to the SEARCH_CHAT_MESSAGES action. 2023-04-29 17:48:58 +01:00
CalDescent
36e944d7e2 Added MAIL and MESSAGE services. 2023-04-29 17:45:38 +01:00
CalDescent
f044166b81 More qdnBase improvements, to hopefully handle all cases correctly. 2023-04-29 17:13:50 +01:00
CalDescent
aed1823afb Added support of simple Range headers when requesting QDN data. 2023-04-28 20:36:06 +01:00
CalDescent
6dfaaf0054 Set charset to UTF-8 in various places that bytes are converted to a string. 2023-04-28 13:06:29 +01:00
CalDescent
45bc2e46d6 Improved metadata trimming, to better handle multibyte UTF-8 characters. 2023-04-28 12:48:38 +01:00
CalDescent
46e2e1043d Fixed issue with <base href> introduced in v4.0.0 2023-04-28 12:18:27 +01:00
CalDescent
a3518d1f05 Revert "Fixed bug with base path."
This reverts commit ce52b39495.
2023-04-28 12:13:31 +01:00
CalDescent
0a1ab3d685 Added GET_QDN_RESOURCE_METADATA action. 2023-04-28 10:57:04 +01:00
CalDescent
5dbacc4db3 Added "Accept-Ranges" header when serving arbitrary data.
Allows for video seeking when using URL playback, even though the Range header isn't implemented yet. This could be heavily optimized by adding full support of the Range/Content-Range headers, however this is still a big step forward as it allows for (inefficient) seeking.
2023-04-28 10:12:16 +01:00
CalDescent
1ce2dcfb2b Fixed bug which prevented qortal:// URLs from working properly in most cases. 2023-04-25 08:33:33 +01:00
CalDescent
ed6333f82e Allow for faster and more frequent retries when QDN data fails to be retrieved (thanks to suggestions from @xspektrex) 2023-04-23 19:14:28 +01:00
CalDescent
f27c9193c7 Auto delete any metadata files that are unreadable (e.g. due to being empty, or invalid JSON). 2023-04-23 11:30:42 +01:00
CalDescent
e48529704c Bump version to 4.0.0 2023-04-22 16:08:09 +01:00
CalDescent
53508f9298 Fixed problems in last commit. 2023-04-22 11:33:59 +01:00
CalDescent
33aeec7e87 Added various new service types, in preparation for Q-Apps release. 2023-04-22 11:00:21 +01:00
QuickMythril
8f847d3689 Upgraded to TLSv1.3 2023-04-21 19:30:29 -04:00
CalDescent
16dc23ddc7 Added new actions to gateway handler. 2023-04-21 21:45:16 +01:00
CalDescent
e80494b784 Fixed unit test. 2023-04-21 20:22:18 +01:00
CalDescent
111ec3b483 Fixed typo 2023-04-21 20:05:24 +01:00
CalDescent
db4a9ee880 Return "Resource does not exist" error if requesting a non-existent resource via GET_QDN_RESOURCE_URL. 2023-04-21 19:50:01 +01:00
CalDescent
b1ebe1864b Fixed bug in error handling. 2023-04-21 19:27:24 +01:00
CalDescent
3c251c35ea Fixed divide by zero error in GET /arbitrary/resource/status/* 2023-04-21 18:21:41 +01:00
CalDescent
4954a1744b Fixed case sensitivity bugs. 2023-04-21 17:47:29 +01:00
QuickMythril
a7bbad17d7 Merge pull request #118 from QuickMythril/temp-test
Added missing parameter to test
2023-04-21 11:06:09 -04:00
QuickMythril
8ca9423c52 Added missing parameter to test 2023-04-21 10:58:09 -04:00
CalDescent
32b9b7e578 Use a temporary file when reading on-chain data. 2023-04-21 13:59:29 +01:00
CalDescent
f045e10ada Removed all case sensitivity when searching names. 2023-04-21 12:56:15 +01:00
CalDescent
560282dc1d Added "exactMatchNames" parameter to GET /arbitrary/resources/search 2023-04-21 12:55:59 +01:00
CalDescent
9cd6372161 Improved GET /admin/settings/{setting} further, in order to support all settings (fixes ones such as bitcoinNet). 2023-04-21 12:06:16 +01:00
CalDescent
2370a67b8a Merge branch 'master' into q-apps 2023-04-21 11:07:01 +01:00
CalDescent
0993903aa0 Added GET /settings/{setting} endpoint
Based on work by @QuickMythril, but modified to be generic.
2023-04-21 11:03:24 +01:00
CalDescent
f5e9b91d6b Merge pull request #116 from QuickMythril/restart
Added API call for restarting node
2023-04-21 10:27:58 +01:00
CalDescent
7fe507a497 Merge pull request #117 from QuickMythril/rm-swagger-check
Removed 3rd-party swagger server validation
2023-04-21 10:26:19 +01:00
CalDescent
10f12221c9 Fixed exception in readJson(), and removed some duplicated code. 2023-04-21 09:42:04 +01:00
QuickMythril
85980e4cfc Removed 3rd-party swagger server validation 2023-04-20 16:41:47 -04:00
QuickMythril
7bb6b84e86 Added API call for restarting node 2023-04-20 16:23:57 -04:00
CalDescent
dc25d33739 Merge branch 'master' into q-apps 2023-04-19 20:57:31 +01:00
CalDescent
358e67b050 Added "bindAddressFallback" setting, which defaults to "0.0.0.0".
Should fix problems on systems unable to use IPv6 wildcard (::) for listening, and avoids having to manually specify "bindAddress": "0.0.0.0" in settings.json.
2023-04-19 20:56:47 +01:00
CalDescent
8331241d75 Bump version to 3.9.1 2023-04-18 19:01:45 +01:00
CalDescent
e041748b48 Improved name rebuilding code, to handle some more complex scenarios. 2023-04-16 13:59:25 +01:00
CalDescent
06691af729 Merge pull request #113 from QuickMythril/drew-api
Added API calls to get node settings & get, create, and vote on polls
2023-04-16 11:43:31 +01:00
CalDescent
cfe6dfcd1c If nameFilter contains an empty or nonexistent list, return an empty array. 2023-04-15 18:27:55 +01:00
CalDescent
3f00cda847 "nameListFilter" added to LIST_QDN_RESOURCES and SEARCH_QDN_RESOURCES Q-Apps actions. 2023-04-15 16:02:25 +01:00
CalDescent
a286db2dfd "namefilter" param in GET /arbitrary/resources/search is now exact match, which makes more sense when filtering results by names in a list. 2023-04-15 15:55:52 +01:00
CalDescent
28bd4adcd2 Removed GET /arbitrary/resources/names endpoint, as it's unused and doesn't scale well. 2023-04-15 15:42:47 +01:00
CalDescent
61b7cdd025 Added "followedonly" and "excludeblocked" params to GET /arbitrary/resources and GET /arbitrary/resources/search, as well as LIST_QDN_RESOURCES and SEARCH_QDN_RESOURCES Q-Apps actions. 2023-04-15 15:24:10 +01:00
CalDescent
250245d5e1 Added new list management actions to Q-Apps documentation. 2023-04-15 14:34:30 +01:00
CalDescent
0258d2bcb6 Fixed layout issues recently introduced in documentation. 2023-04-15 14:31:41 +01:00
QuickMythril
735de93848 Removed internal use parameter from API endpoint 2023-04-15 09:25:28 -04:00
QuickMythril
57485bfe36 Removed check from poll tx that creator is owner 2023-04-15 09:11:27 -04:00
CalDescent
ed05560413 Gateway auth alert box replaced with a modal overlay in the lower right hand corner of the screen. 2023-04-15 10:11:33 +01:00
CalDescent
892b667f86 Fixed console errors seen in certain cases. 2023-04-15 09:57:26 +01:00
CalDescent
ea7a2224d3 Allow the name of a list to be specified as a "namefilter" param in GET /arbitrary/resources/search. Any names in the list will be included in the search (same as if they were specified manually via &name=). 2023-04-14 17:44:06 +01:00
CalDescent
20893879ca Allow multiple name parameters to optionally be included in GET /arbitrary/resources/search
Also updated SEARCH_QDN_RESOURCES action, to allow multiple names to be optionally specified via the "names" parameter.
2023-04-14 17:17:05 +01:00
CalDescent
b08e845dbb Updated docs to include sending of foreign coins 2023-04-14 16:24:27 +01:00
CalDescent
e60cd96514 Fixed occasional NPE seen in ArbitraryDataFileMessage 2023-04-14 11:02:27 +01:00
CalDescent
e2a2a1f956 Fixed bug with GET_QDN_RESOURCE_URL action. 2023-04-11 19:03:56 +01:00
CalDescent
7f53983d77 Added support for hash routing in URL shown in address bar. 2023-04-09 18:21:19 +01:00
CalDescent
ce52b39495 Fixed bug with base path. 2023-04-09 17:55:41 +01:00
CalDescent
3296779125 Update address bar when navigating within an app. 2023-04-09 17:11:20 +01:00
CalDescent
3dcd9d237c Added "_qdnBase" global javascript var, for apps to use as a basename / path prefix. 2023-04-07 19:03:28 +01:00
QuickMythril
23ec71d7be Renamed API calls from "voting" to "polls" 2023-04-06 04:48:18 -04:00
QuickMythril
5bbde4dcdb Added API calls to get polls & node settings 2023-04-06 04:41:51 -04:00
QuickMythril
dc2da8b283 Merge pull request #37 from DrewMPeacock/VotingTransactions
Fix up CREATE_POLL and VOTE_ON_POLL transactions to process and valid…
2023-04-06 03:35:58 -04:00
QuickMythril
f3772d19f5 Merge pull request #36 from DrewMPeacock/master
Add API handles to build CREATE_POLL and VOTE_ON_POLL transactions.
2023-04-06 03:34:50 -04:00
CalDescent
35def54ecc Added support for multiple block/follow lists.
Any list with the following prefix will be used in block/follow logic:

blockedNames
blockedAddresses
followedNames

For instance, any names in a list named "blockedNames_CustomBlockList" would also be blocked, along with those in the standard "blockedNames" list.

This will ultimately allow apps to offer custom block/follow lists to users (once list functionality is added to the Q-Apps API).
2023-04-02 14:42:49 +01:00
CalDescent
2086a2c476 Moved block/follow utility methods to a new ListUtils class 2023-04-02 10:58:16 +01:00
CalDescent
4835e5732d Fixed issue which caused UI to lock up when using qortalRequest() 2023-04-02 10:06:02 +01:00
CalDescent
d831972005 Fixed NPE in isMetadataEqual() 2023-03-31 18:41:58 +01:00
CalDescent
f6914821d3 Always use PUT for on-chain data. 2023-03-31 18:30:05 +01:00
CalDescent
a4551245cb Improved error logging in BlockArchiveUtils.importFromArchive() 2023-03-12 19:08:57 +00:00
CalDescent
e4f45c1a70 Break out of orphan loop when stopping. 2023-03-12 19:08:07 +00:00
CalDescent
bc44b998dc The transaction sequences reshape now fetches transactions from the archive.
This is required as it's the only place that holds the original order of each block's transactions. We cannot sort them, because the comparator function for transactions has some dependencies on the existing order for AT transactions. As a result, topOnly nodes cannot perform this reshape, and will be unable to run this version.
2023-03-10 21:29:35 +00:00
CalDescent
b89a35ac69 Merge branch 'master' into block-sequence
# Conflicts:
#	src/main/java/org/qortal/controller/Controller.java
#	src/main/java/org/qortal/repository/RepositoryManager.java
2023-03-10 19:52:05 +00:00
CalDescent
9566bda279 Merge branch 'master' into block-sequence 2023-02-26 12:55:35 +00:00
CalDescent
20d4e88fab Fixed API endpoints relying on getTransactionsFromSignature(), which therefore won't have worked properly since core V2. 2023-02-12 13:21:54 +00:00
CalDescent
a8c27be18a Modified AT and transaction repository queries to use Transactions.block_sequence instead of BlockTransactions.sequence.
The former is available for all blocks, whereas the latter is only available for unpruned blocks.

Also removed joins with the Blocks table - as the Blocks table is also pruned - and instead retrieved the height from the Transactions table.
2023-02-12 13:21:41 +00:00
CalDescent
af6be759e7 Fixed long term issue where logs would report "Repository in use by another process?" when the database actually failed to start for some other reason. It will now log the correct reason. 2023-02-12 13:20:31 +00:00
CalDescent
896d814385 Add block_sequence to Transactions table, and populate all past transactions.
This data was being lost when pruning the BlockTransactions table.

Note: on first run this will reshape the db, which can take several minutes.
2023-02-12 13:20:23 +00:00
DrewMPeacock
1abceada20 Fix up CREATE_POLL and VOTE_ON_POLL transactions to process and validate.
Added rule to enforce that a poll creator is also its owner.
2022-09-09 11:20:46 -06:00
DrewMPeacock
4c463f65b7 Add API handles to build CREATE_POLL and VOTE_ON_POLL transactions. 2022-08-08 15:58:46 -06:00
122 changed files with 5495 additions and 1462 deletions

204
Q-Apps.md
View File

@@ -42,10 +42,15 @@ A "default" resource refers to one without an identifier. For example, when a we
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,
@@ -55,7 +60,25 @@ METADATA,
BLOG,
BLOG_POST,
BLOG_COMMENT,
GIF_REPOSITORY
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,
@@ -63,6 +86,20 @@ 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
@@ -173,6 +210,7 @@ To take things a step further, the qortalRequest() function can be used to inter
- 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.
@@ -214,14 +252,20 @@ Here is a list of currently supported actions:
- GET_USER_ACCOUNT
- GET_ACCOUNT_DATA
- GET_ACCOUNT_NAMES
- SEARCH_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
@@ -237,8 +281,9 @@ Here is a list of currently supported actions:
- FETCH_BLOCK_RANGE
- SEARCH_TRANSACTIONS
- GET_PRICE
- GET_QDN_RESOURCE_URL
- LINK_TO_QDN_RESOURCE
- GET_LIST_ITEMS
- ADD_LIST_ITEMS
- DELETE_LIST_ITEM
More functionality will be added in the future.
@@ -280,6 +325,18 @@ let res = await qortalRequest({
});
```
### Search names
```
let res = await qortalRequest({
action: "SEARCH_NAMES",
query: "search query goes here",
prefix: false, // Optional - if true, only the beginning of the name is matched
limit: 100,
offset: 0,
reverse: false
});
```
### Get name data
```
let res = await qortalRequest({
@@ -299,6 +356,8 @@ let res = await qortalRequest({
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
@@ -314,9 +373,42 @@ let res = await qortalRequest({
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
mode: "LATEST", // Optional - whether to return all resources or just the latest for a name/service combination. Possible values: ALL,LATEST. Default: LATEST
minLevel: 1, // Optional - whether to filter results by minimum account level
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
// before: 1683546000000, // Optional - limit to resources created before timestamp
// after: 1683546000000, // Optional - limit to resources created after timestamp
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
exactMatchNames: true, // Optional - if true, partial name matches are excluded
default: false, // Optional - if true, only resources without identifiers are returned
mode: "LATEST", // Optional - whether to return all resources or just the latest for a name/service combination. Possible values: ALL,LATEST. Default: LATEST
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
// before: 1683546000000, // Optional - limit to resources created before timestamp
// after: 1683546000000, // Optional - limit to resources created after timestamp
limit: 100,
offset: 0,
reverse: true
@@ -354,7 +446,8 @@ let res = await qortalRequest({
action: "GET_QDN_RESOURCE_STATUS",
name: "QortalDemo",
service: "THUMBNAIL",
identifier: "qortal_avatar" // Optional
identifier: "qortal_avatar", // Optional
build: true // Optional - request that the resource is fetched & built in the background
});
```
@@ -369,11 +462,21 @@ let res = await qortalRequest({
// 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.
```
await qortalRequest({
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",
@@ -387,7 +490,9 @@ await qortalRequest({
// tag2: "strings", // Optional
// tag3: "can", // Optional
// tag4: "go", // Optional
// tag5: "here" // Optional
// tag5: "here", // Optional
// encrypt: true, // Optional - to be used with a private service
// recipientPublicKey: "publickeygoeshere" // Only required if `encrypt` is set to true
});
```
@@ -395,7 +500,7 @@ await qortalRequest({
_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.
```
await qortalRequest({
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
@@ -410,7 +515,9 @@ await qortalRequest({
// tag2: "strings", // Optional
// tag3: "can", // Optional
// tag4: "go", // Optional
// tag5: "here" // 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 ...
@@ -418,10 +525,32 @@ await qortalRequest({
});
```
### 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_
```
await qortalRequest({
let res = await qortalRequest({
action: "GET_WALLET_BALANCE",
coin: "QORT"
});
@@ -446,7 +575,7 @@ let res = await qortalRequest({
### Send QORT to address
_Requires user approval_
```
await qortalRequest({
let res = await qortalRequest({
action: "SEND_COIN",
coin: "QORT",
destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2",
@@ -454,6 +583,18 @@ await qortalRequest({
});
```
### Send foreign coin to address
_Requires user approval_<br />
Note: default fees can be found [here](https://github.com/Qortal/qortal-ui/blob/master/plugins/plugins/core/qdn/browser/browser.src.js#L205-L209).
```
let res = await qortalRequest({
action: "SEND_COIN",
coin: "LTC",
destinationAddress: "LSdTvMHRm8sScqwCi6x9wzYQae8JeZhx6y",
amount: 1.00000000, // 1 LTC
fee: 0.00000020 // Optional fee per byte (default fee used if omitted, recommended) - not used for QORT or ARRR
});
```
### Search or list chat messages
```
@@ -466,6 +607,7 @@ let res = await qortalRequest({
// reference: "reference", // Optional
// chatReference: "chatreference", // Optional
// hasChatReference: true, // Optional
encoding: "BASE64", // Optional (defaults to BASE58 if omitted)
limit: 100,
offset: 0,
reverse: true
@@ -475,7 +617,7 @@ let res = await qortalRequest({
### Send a group chat message
_Requires user approval_
```
await qortalRequest({
let res = await qortalRequest({
action: "SEND_CHAT_MESSAGE",
groupId: 0,
message: "Test"
@@ -485,7 +627,7 @@ await qortalRequest({
### Send a private chat message
_Requires user approval_
```
await qortalRequest({
let res = await qortalRequest({
action: "SEND_CHAT_MESSAGE",
destinationAddress: "QZLJV7wbaFyxaoZQsjm6rb9MWMiDzWsqM2",
message: "Test"
@@ -505,7 +647,7 @@ let res = await qortalRequest({
### Join a group
_Requires user approval_
```
await qortalRequest({
let res = await qortalRequest({
action: "JOIN_GROUP",
groupId: 100
});
@@ -611,6 +753,7 @@ let res = await qortalRequest({
```
### 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",
@@ -622,6 +765,7 @@ let url = await qortalRequest({
```
### 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",
@@ -631,6 +775,7 @@ let url = await qortalRequest({
```
### 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",
@@ -661,9 +806,42 @@ let res = await qortalRequest({
});
```
### 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:

View File

@@ -3,7 +3,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>org.qortal</groupId>
<artifactId>qortal</artifactId>
<version>3.9.0</version>
<version>4.2.2</version>
<packaging>jar</packaging>
<properties>
<skipTests>true</skipTests>
@@ -148,6 +148,7 @@
tagsSorter: "alpha",
operationsSorter:
"alpha",
validatorUrl: false,
</value>
</replacement>
</replacements>

View File

@@ -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),

View File

@@ -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 {
@@ -95,7 +96,7 @@ public class ApiService {
throw new RuntimeException("Failed to start SSL API due to broken keystore");
// BouncyCastle-specific SSLContext build
SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE");
SSLContext sslContext = SSLContext.getInstance("TLSv1.3", "BCJSSE");
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE");
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC");
@@ -125,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);
}

View File

@@ -0,0 +1,173 @@
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.server.*;
import org.eclipse.jetty.server.handler.ErrorHandler;
import org.eclipse.jetty.server.handler.InetAccessHandler;
import org.eclipse.jetty.servlet.FilterHolder;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.eclipse.jetty.servlets.CrossOriginFilter;
import org.eclipse.jetty.util.ssl.SslContextFactory;
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.repository.DataException;
import org.qortal.settings.Settings;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.KeyStore;
import java.security.SecureRandom;
public class DevProxyService {
private static DevProxyService instance;
private final ResourceConfig config;
private Server server;
private DevProxyService() {
this.config = new ResourceConfig();
this.config.packages("org.qortal.api.proxy.resource", "org.qortal.api.resource");
this.config.register(OpenApiResource.class);
this.config.register(ApiDefinition.class);
this.config.register(AnnotationPostProcessor.class);
}
public static DevProxyService getInstance() {
if (instance == null)
instance = new DevProxyService();
return instance;
}
public Iterable<Class<?>> getResources() {
return this.config.getClasses();
}
public void start() throws DataException {
try {
// Create API server
// SSL support if requested
String keystorePathname = Settings.getInstance().getSslKeystorePathname();
String keystorePassword = Settings.getInstance().getSslKeystorePassword();
if (keystorePathname != null && keystorePassword != null) {
// SSL version
if (!Files.isReadable(Path.of(keystorePathname)))
throw new RuntimeException("Failed to start SSL API due to broken keystore");
// BouncyCastle-specific SSLContext build
SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE");
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE");
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC");
try (InputStream keystoreStream = Files.newInputStream(Paths.get(keystorePathname))) {
keyStore.load(keystoreStream, keystorePassword.toCharArray());
}
keyManagerFactory.init(keyStore, keystorePassword.toCharArray());
sslContext.init(keyManagerFactory.getKeyManagers(), null, new SecureRandom());
SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
sslContextFactory.setSslContext(sslContext);
this.server = new Server();
HttpConfiguration httpConfig = new HttpConfiguration();
httpConfig.setSecureScheme("https");
httpConfig.setSecurePort(Settings.getInstance().getDevProxyPort());
SecureRequestCustomizer src = new SecureRequestCustomizer();
httpConfig.addCustomizer(src);
HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory(httpConfig);
SslConnectionFactory sslConnectionFactory = new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString());
ServerConnector portUnifiedConnector = new ServerConnector(this.server,
new DetectorConnectionFactory(sslConnectionFactory),
httpConnectionFactory);
portUnifiedConnector.setHost(Network.getInstance().getBindAddress());
portUnifiedConnector.setPort(Settings.getInstance().getDevProxyPort());
this.server.addConnector(portUnifiedConnector);
} else {
// Non-SSL
InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress());
InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getDevProxyPort());
this.server = new Server(endpoint);
}
// Error handler
ErrorHandler errorHandler = new ApiErrorHandler();
this.server.setErrorHandler(errorHandler);
// Request logging
if (Settings.getInstance().isDevProxyLoggingEnabled()) {
RequestLogWriter logWriter = new RequestLogWriter("devproxy-requests.log");
logWriter.setAppend(true);
logWriter.setTimeZone("UTC");
RequestLog requestLog = new CustomRequestLog(logWriter, CustomRequestLog.EXTENDED_NCSA_FORMAT);
this.server.setRequestLog(requestLog);
}
// Access handler (currently no whitelist is used)
InetAccessHandler accessHandler = new InetAccessHandler();
this.server.setHandler(accessHandler);
// URL rewriting
RewriteHandler rewriteHandler = new RewriteHandler();
accessHandler.setHandler(rewriteHandler);
// Context
ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS);
context.setContextPath("/");
rewriteHandler.setHandler(context);
// Cross-origin resource sharing
FilterHolder corsFilterHolder = new FilterHolder(CrossOriginFilter.class);
corsFilterHolder.setInitParameter(CrossOriginFilter.ALLOWED_ORIGINS_PARAM, "*");
corsFilterHolder.setInitParameter(CrossOriginFilter.ALLOWED_METHODS_PARAM, "GET, POST, DELETE");
corsFilterHolder.setInitParameter(CrossOriginFilter.CHAIN_PREFLIGHT_PARAM, "false");
context.addFilter(corsFilterHolder, "/*", null);
// API servlet
ServletContainer container = new ServletContainer(this.config);
ServletHolder apiServlet = new ServletHolder(container);
apiServlet.setInitOrder(1);
context.addServlet(apiServlet, "/*");
// Start server
this.server.start();
} catch (Exception e) {
// Failed to start
throw new DataException("Failed to start developer proxy", e);
}
}
public void stop() {
try {
// Stop server
this.server.stop();
} catch (Exception e) {
// Failed to stop
}
this.server = null;
instance = null;
}
}

View File

@@ -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;
@@ -68,7 +69,7 @@ public class DomainMapService {
throw new RuntimeException("Failed to start SSL API due to broken keystore");
// BouncyCastle-specific SSLContext build
SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE");
SSLContext sslContext = SSLContext.getInstance("TLSv1.3", "BCJSSE");
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE");
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC");
@@ -98,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);
}

View File

@@ -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;
@@ -68,7 +69,7 @@ public class GatewayService {
throw new RuntimeException("Failed to start SSL API due to broken keystore");
// BouncyCastle-specific SSLContext build
SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE");
SSLContext sslContext = SSLContext.getInstance("TLSv1.3", "BCJSSE");
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE");
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC");
@@ -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);
}

View File

@@ -13,7 +13,8 @@ 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;
@@ -21,11 +22,13 @@ public class HTMLParser {
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 qdnContext, Service service, String identifier, String theme) {
String inPathWithoutFilename = inPath.contains("/") ? 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 includeResourceIdInPrefix, byte[] data,
String qdnContext, Service service, String identifier, String theme, boolean usingCustomRouting) {
String inPathWithoutFilename = inPath.contains("/") ? inPath.substring(0, inPath.lastIndexOf('/')) : String.format("/%s",inPath);
this.qdnBase = includeResourceIdInPrefix ? String.format("%s/%s", prefix, resourceId) : prefix;
this.qdnBaseWithPath = includeResourceIdInPrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : String.format("%s%s", prefix, inPathWithoutFilename);
this.data = data;
this.qdnContext = qdnContext;
this.resourceId = resourceId;
@@ -33,12 +36,12 @@ public class HTMLParser {
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
@@ -52,16 +55,21 @@ public class HTMLParser {
}
// Escape and add vars
String service = this.service.toString().replace("\"","\\\"");
String name = this.resourceId != null ? this.resourceId.replace("\"","\\\"") : "";
String identifier = this.identifier != null ? this.identifier.replace("\"","\\\"") : "";
String path = this.path != null ? this.path.replace("\"","\\\"") : "";
String theme = this.theme != null ? this.theme.replace("\"","\\\"") : "";
String qdnContextVar = String.format("<script>var _qdnContext=\"%s\"; var _qdnTheme=\"%s\"; var _qdnService=\"%s\"; var _qdnName=\"%s\"; var _qdnIdentifier=\"%s\"; var _qdnPath=\"%s\";</script>", this.qdnContext, theme, service, name, identifier, path);
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
@@ -74,7 +82,7 @@ public class HTMLParser {
}
public static boolean isHtmlFile(String path) {
if (path.endsWith(".html") || path.endsWith(".htm")) {
if (path.endsWith(".html") || path.endsWith(".htm") || path.equals("")) {
return true;
}
return false;

View File

@@ -0,0 +1,6 @@
package org.qortal.api;
public enum SearchMode {
LATEST,
ALL;
}

View File

@@ -48,10 +48,10 @@ public class DomainMapResource {
}
private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier,
String inPath, String secret58, String prefix, boolean usePrefix, boolean async) {
String inPath, String secret58, String prefix, boolean includeResourceIdInPrefix, boolean async) {
ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, identifier, inPath,
secret58, prefix, usePrefix, async, "domainMap", request, response, context);
secret58, prefix, includeResourceIdInPrefix, async, "domainMap", request, response, context);
return renderer.render();
}

View File

@@ -3,6 +3,8 @@ 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.ApiError;
import org.qortal.api.ApiExceptionFactory;
import org.qortal.api.Security;
import org.qortal.arbitrary.ArbitraryDataFile;
import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType;
@@ -11,6 +13,9 @@ import org.qortal.arbitrary.ArbitraryDataRenderer;
import org.qortal.arbitrary.ArbitraryDataResource;
import org.qortal.arbitrary.misc.Service;
import org.qortal.data.arbitrary.ArbitraryResourceStatus;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
@@ -31,36 +36,12 @@ public class GatewayResource {
@Context HttpServletResponse response;
@Context ServletContext context;
/**
* We need to allow resource status checking (and building) via the gateway, as the node's API port
* may not be forwarded and will almost certainly not be authenticated. Since gateways allow for
* all resources to be loaded except those that are blocked, there is no need for authentication.
*/
@GET
@Path("/arbitrary/resource/status/{service}/{name}")
public ArbitraryResourceStatus getDefaultResourceStatus(@PathParam("service") Service service,
@PathParam("name") String name,
@QueryParam("build") Boolean build) {
return this.getStatus(service, name, null, build);
}
@GET
@Path("/arbitrary/resource/status/{service}/{name}/{identifier}")
public ArbitraryResourceStatus getResourceStatus(@PathParam("service") Service service,
@PathParam("name") String name,
@PathParam("identifier") String identifier,
@QueryParam("build") Boolean build) {
return this.getStatus(service, name, identifier, build);
}
private ArbitraryResourceStatus getStatus(Service service, String name, String identifier, Boolean build) {
// If "build=true" has been specified in the query string, build the resource before returning its status
if (build != null && build == true) {
ArbitraryDataReader reader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, null);
try {
ArbitraryDataReader reader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, null);
if (!reader.isBuilding()) {
reader.loadSynchronously(false);
}
@@ -69,8 +50,13 @@ public class GatewayResource {
}
}
ArbitraryDataResource resource = new ArbitraryDataResource(name, ResourceIdType.NAME, service, identifier);
return resource.getStatus(false);
try (final Repository repository = RepositoryManager.getRepository()) {
ArbitraryDataResource resource = new ArbitraryDataResource(name, ResourceIdType.NAME, service, identifier);
return resource.getStatus(repository);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
@@ -90,7 +76,7 @@ public class GatewayResource {
}
private HttpServletResponse parsePath(String inPath, String qdnContext, String secret58, boolean usePrefix, boolean async) {
private HttpServletResponse parsePath(String inPath, String qdnContext, String secret58, boolean includeResourceIdInPrefix, boolean async) {
if (inPath == null || inPath.equals("")) {
// Assume not a real file
@@ -157,7 +143,7 @@ public class GatewayResource {
}
ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(name, ResourceIdType.NAME, service, identifier, outPath,
secret58, prefix, usePrefix, async, qdnContext, request, response, context);
secret58, prefix, includeResourceIdInPrefix, async, qdnContext, request, response, context);
return renderer.render();
}

View File

@@ -0,0 +1,56 @@
package org.qortal.api.model;
import java.util.List;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import io.swagger.v3.oas.annotations.media.Schema;
import org.qortal.data.voting.VoteOnPollData;
@Schema(description = "Poll vote info, including voters")
// All properties to be converted to JSON via JAX-RS
@XmlAccessorType(XmlAccessType.FIELD)
public class PollVotes {
@Schema(description = "List of individual votes")
@XmlElement(name = "votes")
public List<VoteOnPollData> votes;
@Schema(description = "Total number of votes")
public Integer totalVotes;
@Schema(description = "List of vote counts for each option")
public List<OptionCount> voteCounts;
// For JAX-RS
protected PollVotes() {
}
public PollVotes(List<VoteOnPollData> votes, Integer totalVotes, List<OptionCount> voteCounts) {
this.votes = votes;
this.totalVotes = totalVotes;
this.voteCounts = voteCounts;
}
@Schema(description = "Vote info")
// All properties to be converted to JSON via JAX-RS
@XmlAccessorType(XmlAccessType.FIELD)
public static class OptionCount {
@Schema(description = "Option name")
public String optionName;
@Schema(description = "Vote count")
public Integer voteCount;
// For JAX-RS
protected OptionCount() {
}
public OptionCount(String optionName, Integer voteCount) {
this.optionName = optionName;
this.voteCount = voteCount;
}
}
}

View File

@@ -0,0 +1,164 @@
package org.qortal.api.proxy.resource;
import org.qortal.api.ApiError;
import org.qortal.api.ApiExceptionFactory;
import org.qortal.api.HTMLParser;
import org.qortal.arbitrary.misc.Service;
import org.qortal.controller.DevProxyManager;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.core.Context;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.ConnectException;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.util.Enumeration;
@Path("/")
public class DevProxyServerResource {
@Context HttpServletRequest request;
@Context HttpServletResponse response;
@Context ServletContext context;
@GET
public HttpServletResponse getProxyIndex() {
return this.proxy("/");
}
@GET
@Path("{path:.*}")
public HttpServletResponse getProxyPath(@PathParam("path") String inPath) {
return this.proxy(inPath);
}
private HttpServletResponse proxy(String inPath) {
try {
String source = DevProxyManager.getInstance().getSourceHostAndPort();
if (!inPath.startsWith("/")) {
inPath = "/" + inPath;
}
String queryString = request.getQueryString() != null ? "?" + request.getQueryString() : "";
// Open URL
URL url = new URL(String.format("http://%s%s%s", source, inPath, queryString));
HttpURLConnection con = (HttpURLConnection) url.openConnection();
// Proxy the request data
this.proxyRequestToConnection(request, con);
try {
// Make the request and proxy the response code
response.setStatus(con.getResponseCode());
}
catch (ConnectException e) {
// Tey converting localhost / 127.0.0.1 to IPv6 [::1]
if (source.startsWith("localhost") || source.startsWith("127.0.0.1")) {
int port = 80;
String[] parts = source.split(":");
if (parts.length > 1) {
port = Integer.parseInt(parts[1]);
}
source = String.format("[::1]:%d", port);
}
// Retry connection
url = new URL(String.format("http://%s%s%s", source, inPath, queryString));
con = (HttpURLConnection) url.openConnection();
this.proxyRequestToConnection(request, con);
response.setStatus(con.getResponseCode());
}
// Proxy the response data back to the caller
this.proxyConnectionToResponse(con, response, inPath);
} catch (IOException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, e.getMessage());
}
return response;
}
private void proxyRequestToConnection(HttpServletRequest request, HttpURLConnection con) throws ProtocolException {
// Proxy the request method
con.setRequestMethod(request.getMethod());
// Proxy the request headers
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String headerName = headerNames.nextElement();
String headerValue = request.getHeader(headerName);
con.setRequestProperty(headerName, headerValue);
}
// TODO: proxy any POST parameters from "request" to "con"
}
private void proxyConnectionToResponse(HttpURLConnection con, HttpServletResponse response, String inPath) throws IOException {
// Proxy the response headers
for (int i = 0; ; i++) {
String headerKey = con.getHeaderFieldKey(i);
String headerValue = con.getHeaderField(i);
if (headerKey != null && headerValue != null) {
response.addHeader(headerKey, headerValue);
continue;
}
break;
}
// Read the response body
InputStream inputStream = con.getInputStream();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
byte[] data = outputStream.toByteArray(); // TODO: limit file size that can be read into memory
// Close the streams
outputStream.close();
inputStream.close();
// Extract filename
String filename = "";
if (inPath.contains("/")) {
String[] parts = inPath.split("/");
if (parts.length > 0) {
filename = parts[parts.length - 1];
}
}
// Parse and modify output if needed
if (HTMLParser.isHtmlFile(filename)) {
// HTML file - needs to be parsed
HTMLParser htmlParser = new HTMLParser("", inPath, "", false, data, "proxy", Service.APP, null, "light", true);
htmlParser.addAdditionalHeaderTags();
response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' data: blob:; img-src 'self' data: blob:; connect-src 'self' ws:; font-src 'self' data:;");
response.setContentType(con.getContentType());
response.setContentLength(htmlParser.getData().length);
response.getOutputStream().write(htmlParser.getData());
}
else {
// Regular file - can be streamed directly
response.addHeader("Content-Security-Policy", "default-src 'self'");
response.setContentType(con.getContentType());
response.setContentLength(data.length);
response.getOutputStream().write(data);
}
}
}

View File

@@ -45,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.ArbitraryDataCacheManager;
import org.qortal.controller.arbitrary.ArbitraryDataRenderManager;
import org.qortal.controller.arbitrary.ArbitraryDataStorageManager;
import org.qortal.controller.arbitrary.ArbitraryMetadataManager;
@@ -65,10 +66,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")
@@ -89,12 +87,12 @@ public class ArbitraryResource {
"- If default is set to true, only resources without identifiers will be returned.",
responses = {
@ApiResponse(
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ArbitraryResourceInfo.class))
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ArbitraryResourceData.class))
)
}
)
@ApiErrors({ApiError.REPOSITORY_ISSUE})
public List<ArbitraryResourceInfo> getResources(
public List<ArbitraryResourceData> getResources(
@QueryParam("service") Service service,
@QueryParam("name") String name,
@QueryParam("identifier") String identifier,
@@ -102,7 +100,9 @@ public class ArbitraryResource {
@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) {
@@ -125,29 +125,23 @@ public class ArbitraryResource {
// Filter using single name
names = Arrays.asList(name);
}
else if (nameFilter != null) {
else if (nameListFilter != null) {
// Filter using supplied list of names
names = ResourceListManager.getInstance().getStringsInList(nameFilter);
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);
List<ArbitraryResourceData> resources = repository.getArbitraryRepository()
.getArbitraryResources(service, identifier, names, defaultRes, followedOnly, excludeBlocked,
includeMetadata, includeStatus, limit, offset, reverse);
if (resources == null) {
return new ArrayList<>();
}
if (includeStatus != null && includeStatus) {
resources = ArbitraryTransactionUtils.addStatusToResources(resources);
}
if (includeMetadata != null && includeMetadata) {
resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
}
return resources;
} catch (DataException e) {
@@ -162,43 +156,66 @@ public class ArbitraryResource {
"If default is set to true, only resources without identifiers will be returned.",
responses = {
@ApiResponse(
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ArbitraryResourceInfo.class))
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ArbitraryResourceData.class))
)
}
)
@ApiErrors({ApiError.REPOSITORY_ISSUE})
public List<ArbitraryResourceInfo> searchResources(
public List<ArbitraryResourceData> searchResources(
@QueryParam("service") Service service,
@Parameter(description = "Query (searches both name and identifier fields)") @QueryParam("query") String query,
@Parameter(description = "Query (searches name, identifier, title and description 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") String name,
@Parameter(description = "Name (searches name field only)") @QueryParam("name") List<String> names,
@Parameter(description = "Title (searches title metadata field only)") @QueryParam("title") String title,
@Parameter(description = "Description (searches description metadata field only)") @QueryParam("description") String description,
@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 = "Search mode") @QueryParam("mode") SearchMode mode,
@Parameter(description = "Min level") @QueryParam("minlevel") Integer minLevel,
@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(description = "Creation date before timestamp") @QueryParam("before") Long before,
@Parameter(description = "Creation date after timestamp") @QueryParam("after") Long after,
@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<ArbitraryResourceInfo> resources = repository.getArbitraryRepository()
.searchArbitraryResources(service, query, identifier, name, usePrefixOnly, defaultRes, limit, offset, reverse);
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<ArbitraryResourceData> resources = repository.getArbitraryRepository()
.searchArbitraryResources(service, query, identifier, names, title, description, usePrefixOnly,
exactMatchNames, defaultRes, mode, minLevel, followedOnly, excludeBlocked, includeMetadata, includeStatus,
before, after, limit, offset, reverse);
if (resources == null) {
return new ArrayList<>();
}
if (includeStatus != null && includeStatus) {
resources = ArbitraryTransactionUtils.addStatusToResources(resources);
}
if (includeMetadata != null && includeMetadata) {
resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
}
return resources;
} catch (DataException e) {
@@ -206,67 +223,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 = ArbitraryTransactionUtils.addStatusToResources(resources);
}
if (includeMetadata != null && includeMetadata) {
resources = ArbitraryTransactionUtils.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(
@@ -278,16 +234,14 @@ public class ArbitraryResource {
)
}
)
@SecurityRequirement(name = "apiKey")
public ArbitraryResourceStatus getDefaultResourceStatus(@HeaderParam(Security.API_KEY_HEADER) String apiKey,
@PathParam("service") Service service,
public ArbitraryResourceStatus getDefaultResourceStatus(@PathParam("service") Service service,
@PathParam("name") String name,
@QueryParam("build") Boolean build) {
if (!Settings.getInstance().isQDNAuthBypassEnabled())
Security.requirePriorAuthorizationOrApiKey(request, name, service, null, apiKey);
Security.requirePriorAuthorizationOrApiKey(request, name, service, null, null);
return ArbitraryTransactionUtils.getStatus(service, name, null, build);
return ArbitraryTransactionUtils.getStatus(service, name, null, build, true);
}
@GET
@@ -301,14 +255,12 @@ public class ArbitraryResource {
)
}
)
@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) {
public FileProperties getResourceProperties(@PathParam("service") Service service,
@PathParam("name") String name,
@PathParam("identifier") String identifier) {
if (!Settings.getInstance().isQDNAuthBypassEnabled())
Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier, apiKey);
Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier, null);
return this.getFileProperties(service, name, identifier);
}
@@ -324,17 +276,15 @@ public class ArbitraryResource {
)
}
)
@SecurityRequirement(name = "apiKey")
public ArbitraryResourceStatus getResourceStatus(@HeaderParam(Security.API_KEY_HEADER) String apiKey,
@PathParam("service") Service service,
public ArbitraryResourceStatus getResourceStatus(@PathParam("service") Service service,
@PathParam("name") String name,
@PathParam("identifier") String identifier,
@QueryParam("build") Boolean build) {
if (!Settings.getInstance().isQDNAuthBypassEnabled())
Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier, apiKey);
Security.requirePriorAuthorizationOrApiKey(request, name, service, identifier, null);
return ArbitraryTransactionUtils.getStatus(service, name, identifier, build);
return ArbitraryTransactionUtils.getStatus(service, name, identifier, build, true);
}
@@ -519,21 +469,19 @@ public class ArbitraryResource {
summary = "List arbitrary resources hosted by this node",
responses = {
@ApiResponse(
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ArbitraryResourceInfo.class))
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ArbitraryResourceData.class))
)
}
)
@ApiErrors({ApiError.REPOSITORY_ISSUE})
public List<ArbitraryResourceInfo> getHostedResources(
public List<ArbitraryResourceData> getHostedResources(
@HeaderParam(Security.API_KEY_HEADER) String apiKey,
@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,
@QueryParam("query") String query) {
Security.checkApiCallAllowed(request);
List<ArbitraryResourceInfo> resources = new ArrayList<>();
List<ArbitraryResourceData> resources = new ArrayList<>();
try (final Repository repository = RepositoryManager.getRepository()) {
@@ -549,22 +497,15 @@ public class ArbitraryResource {
if (transactionData.getService() == null) {
continue;
}
ArbitraryResourceInfo arbitraryResourceInfo = new ArbitraryResourceInfo();
arbitraryResourceInfo.name = transactionData.getName();
arbitraryResourceInfo.service = transactionData.getService();
arbitraryResourceInfo.identifier = transactionData.getIdentifier();
if (!resources.contains(arbitraryResourceInfo)) {
resources.add(arbitraryResourceInfo);
ArbitraryResourceData arbitraryResourceData = new ArbitraryResourceData();
arbitraryResourceData.name = transactionData.getName();
arbitraryResourceData.service = transactionData.getService();
arbitraryResourceData.identifier = transactionData.getIdentifier();
if (!resources.contains(arbitraryResourceData)) {
resources.add(arbitraryResourceData);
}
}
if (includeStatus != null && includeStatus) {
resources = ArbitraryTransactionUtils.addStatusToResources(resources);
}
if (includeMetadata != null && includeMetadata) {
resources = ArbitraryTransactionUtils.addMetadataToResources(resources);
}
return resources;
} catch (DataException e) {
@@ -591,8 +532,14 @@ public class ArbitraryResource {
@PathParam("identifier") String identifier) {
Security.checkApiCallAllowed(request);
ArbitraryDataResource resource = new ArbitraryDataResource(name, ResourceIdType.NAME, service, identifier);
return resource.delete(false);
try (final Repository repository = RepositoryManager.getRepository()) {
ArbitraryDataResource resource = new ArbitraryDataResource(name, ResourceIdType.NAME, service, identifier);
return resource.delete(repository, false);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
@POST
@@ -684,9 +631,7 @@ public class ArbitraryResource {
)
}
)
@SecurityRequirement(name = "apiKey")
public HttpServletResponse get(@HeaderParam(Security.API_KEY_HEADER) String apiKey,
@PathParam("service") Service service,
public HttpServletResponse get(@PathParam("service") Service service,
@PathParam("name") String name,
@QueryParam("filepath") String filepath,
@QueryParam("encoding") String encoding,
@@ -719,9 +664,7 @@ public class ArbitraryResource {
)
}
)
@SecurityRequirement(name = "apiKey")
public HttpServletResponse get(@HeaderParam(Security.API_KEY_HEADER) String apiKey,
@PathParam("service") Service service,
public HttpServletResponse get(@PathParam("service") Service service,
@PathParam("name") String name,
@PathParam("identifier") String identifier,
@QueryParam("filepath") String filepath,
@@ -732,7 +675,7 @@ public class ArbitraryResource {
// Authentication can be bypassed in the settings, for those running public QDN nodes
if (!Settings.getInstance().isQDNAuthBypassEnabled()) {
Security.checkApiCallAllowed(request, apiKey);
Security.checkApiCallAllowed(request, null);
}
return this.download(service, name, identifier, filepath, encoding, rebuild, async, attempts);
@@ -757,13 +700,9 @@ 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 {
@@ -1170,6 +1109,36 @@ public class ArbitraryResource {
}
@POST
@Path("/resources/cache/rebuild")
@Operation(
summary = "Rebuild arbitrary resources cache from transactions",
responses = {
@ApiResponse(
description = "true on success",
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "boolean"
)
)
)
}
)
@SecurityRequirement(name = "apiKey")
public String rebuildCache(@HeaderParam(Security.API_KEY_HEADER) String apiKey) {
Security.checkApiCallAllowed(request);
try (final Repository repository = RepositoryManager.getRepository()) {
ArbitraryDataCacheManager.getInstance().buildArbitraryResourcesCache(repository, true);
return "true";
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage());
}
}
// Shared methods
private String preview(String directoryPath, Service service) {
@@ -1216,7 +1185,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);
}
@@ -1274,7 +1247,7 @@ 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();
}
}
@@ -1306,15 +1279,16 @@ 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, String encoding, boolean rebuild, boolean async, Integer maxAttempts) {
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
try {
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
int attempts = 0;
if (maxAttempts == null) {
@@ -1354,7 +1328,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];
}
@@ -1364,20 +1338,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);
@@ -1390,8 +1394,8 @@ public class ArbitraryResource {
}
private FileProperties getFileProperties(Service service, String name, String identifier) {
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
try {
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
arbitraryDataReader.loadSynchronously(false);
java.nio.file.Path outputPath = arbitraryDataReader.getFilePath();
if (outputPath == null) {

View File

@@ -222,14 +222,25 @@ public class BlocksResource {
}
try (final Repository repository = RepositoryManager.getRepository()) {
// Check if the block exists in either the database or archive
if (repository.getBlockRepository().getHeightFromSignature(signature) == 0 &&
repository.getBlockArchiveRepository().getHeightFromSignature(signature) == 0) {
// Not found in either the database or archive
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
}
// Check if the block exists in either the database or archive
int height = repository.getBlockRepository().getHeightFromSignature(signature);
if (height == 0) {
height = repository.getBlockArchiveRepository().getHeightFromSignature(signature);
if (height == 0) {
// Not found in either the database or archive
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
}
}
return repository.getBlockRepository().getTransactionsFromSignature(signature, limit, offset, reverse);
List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, height, height);
// Expand signatures to transactions
List<TransactionData> transactions = new ArrayList<>(signatures.size());
for (byte[] s : signatures) {
transactions.add(repository.getTransactionRepository().fromSignature(s));
}
return transactions;
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}

View File

@@ -119,6 +119,75 @@ public class ChatResource {
}
}
@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(

View File

@@ -115,6 +115,9 @@ public class CrossChainResource {
crossChainTrades.sort((a, b) -> Longs.compare(a.creationTimestamp, b.creationTimestamp));
}
// Remove any trades that have had too many failures
crossChainTrades = TradeBot.getInstance().removeFailedTrades(repository, crossChainTrades);
if (limit != null && limit > 0) {
// Make sure to not return more than the limit
int upperLimit = Math.min(limit, crossChainTrades.size());
@@ -129,6 +132,64 @@ public class CrossChainResource {
}
}
@GET
@Path("/tradeoffers/hidden")
@Operation(
summary = "Find cross-chain trade offers that have been hidden due to too many failures",
responses = {
@ApiResponse(
content = @Content(
array = @ArraySchema(
schema = @Schema(
implementation = CrossChainTradeData.class
)
)
)
)
}
)
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
public List<CrossChainTradeData> getHiddenTradeOffers(
@Parameter(
description = "Limit to specific blockchain",
example = "LITECOIN",
schema = @Schema(implementation = SupportedBlockchain.class)
) @QueryParam("foreignBlockchain") SupportedBlockchain foreignBlockchain) {
final boolean isExecutable = true;
List<CrossChainTradeData> crossChainTrades = new ArrayList<>();
try (final Repository repository = RepositoryManager.getRepository()) {
Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain);
for (Map.Entry<ByteArray, Supplier<ACCT>> acctInfo : acctsByCodeHash.entrySet()) {
byte[] codeHash = acctInfo.getKey().value;
ACCT acct = acctInfo.getValue().get();
List<ATData> atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, null, null, null);
for (ATData atData : atsData) {
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData);
if (crossChainTradeData.mode == AcctMode.OFFERING) {
crossChainTrades.add(crossChainTradeData);
}
}
}
// Sort the trades by timestamp
crossChainTrades.sort((a, b) -> Longs.compare(a.creationTimestamp, b.creationTimestamp));
// Remove trades that haven't failed
crossChainTrades.removeIf(t -> !TradeBot.getInstance().isFailedTrade(repository, t));
crossChainTrades.stream().forEach(CrossChainResource::decorateTradeDataWithPresence);
return crossChainTrades;
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
@GET
@Path("/trade/{ataddress}")
@Operation(

View File

@@ -0,0 +1,96 @@
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.controller.DevProxyManager;
import org.qortal.repository.DataException;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
@Path("/developer")
@Tag(name = "Developer Tools")
public class DeveloperResource {
@Context HttpServletRequest request;
@Context HttpServletResponse response;
@Context ServletContext context;
@POST
@Path("/proxy/start")
@Operation(
summary = "Start proxy server, for real time QDN app/website development",
requestBody = @RequestBody(
description = "Host and port of source webserver to be proxied",
required = true,
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "string",
example = "127.0.0.1:5173"
)
)
),
responses = {
@ApiResponse(
description = "Port number of running server",
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "number"
)
)
)
}
)
@ApiErrors({ApiError.INVALID_CRITERIA})
public Integer startProxy(String sourceHostAndPort) {
// TODO: API key
DevProxyManager devProxyManager = DevProxyManager.getInstance();
try {
devProxyManager.setSourceHostAndPort(sourceHostAndPort);
devProxyManager.start();
return devProxyManager.getPort();
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, e.getMessage());
}
}
@POST
@Path("/proxy/stop")
@Operation(
summary = "Stop proxy server",
responses = {
@ApiResponse(
description = "true if stopped",
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "boolean"
)
)
)
}
)
public boolean stopProxy() {
DevProxyManager devProxyManager = DevProxyManager.getInstance();
devProxyManager.stop();
return !devProxyManager.isRunning();
}
}

View File

@@ -47,6 +47,7 @@ import org.qortal.transform.transaction.RegisterNameTransactionTransformer;
import org.qortal.transform.transaction.SellNameTransactionTransformer;
import org.qortal.transform.transaction.UpdateNameTransactionTransformer;
import org.qortal.utils.Base58;
import org.qortal.utils.Unicode;
@Path("/names")
@Tag(name = "Names")
@@ -63,19 +64,19 @@ public class NamesResource {
description = "registered name info",
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
array = @ArraySchema(schema = @Schema(implementation = NameSummary.class))
array = @ArraySchema(schema = @Schema(implementation = NameData.class))
)
)
}
)
@ApiErrors({ApiError.REPOSITORY_ISSUE})
public List<NameSummary> getAllNames(@Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset,
@Parameter(ref="reverse") @QueryParam("reverse") Boolean reverse) {
public List<NameData> getAllNames(@Parameter(description = "Return only names registered or updated after timestamp") @QueryParam("after") Long after,
@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<NameData> names = repository.getNameRepository().getAllNames(limit, offset, reverse);
// Convert to summary
return names.stream().map(NameSummary::new).collect(Collectors.toList());
return repository.getNameRepository().getAllNames(after, limit, offset, reverse);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
@@ -135,12 +136,13 @@ public class NamesResource {
public NameData getName(@PathParam("name") String name) {
try (final Repository repository = RepositoryManager.getRepository()) {
NameData nameData;
String reducedName = Unicode.sanitize(name);
if (Settings.getInstance().isLite()) {
nameData = LiteNode.getInstance().fetchNameData(name);
}
else {
nameData = repository.getNameRepository().fromName(name);
nameData = repository.getNameRepository().fromReducedName(reducedName);
}
if (nameData == null) {
@@ -155,6 +157,41 @@ 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(description = "Prefix only (if true, only the beginning of the name is matched)") @QueryParam("prefix") Boolean prefixOnly,
@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");
}
boolean usePrefixOnly = Boolean.TRUE.equals(prefixOnly);
return repository.getNameRepository().searchNames(query, usePrefixOnly, limit, offset, reverse);
} catch (ApiException e) {
throw e;
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
@POST
@Path("/register")
@@ -410,4 +447,4 @@ public class NamesResource {
}
}
}
}

View File

@@ -0,0 +1,258 @@
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.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.ws.rs.GET;
import javax.ws.rs.PathParam;
import javax.ws.rs.QueryParam;
import org.qortal.api.ApiException;
import org.qortal.api.model.PollVotes;
import org.qortal.data.voting.PollData;
import org.qortal.data.voting.PollOptionData;
import org.qortal.data.voting.VoteOnPollData;
@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);
}
}
@GET
@Path("/votes/{pollName}")
@Operation(
summary = "Votes on poll",
responses = {
@ApiResponse(
description = "poll votes",
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(implementation = PollVotes.class)
)
)
}
)
@ApiErrors({ApiError.REPOSITORY_ISSUE})
public PollVotes getPollVotes(@PathParam("pollName") String pollName, @QueryParam("onlyCounts") Boolean onlyCounts) {
try (final Repository repository = RepositoryManager.getRepository()) {
PollData pollData = repository.getVotingRepository().fromPollName(pollName);
if (pollData == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.POLL_NO_EXISTS);
List<VoteOnPollData> votes = repository.getVotingRepository().getVotes(pollName);
// Initialize map for counting votes
Map<String, Integer> voteCountMap = new HashMap<>();
for (PollOptionData optionData : pollData.getPollOptions()) {
voteCountMap.put(optionData.getOptionName(), 0);
}
int totalVotes = 0;
for (VoteOnPollData vote : votes) {
String selectedOption = pollData.getPollOptions().get(vote.getOptionIndex()).getOptionName();
if (voteCountMap.containsKey(selectedOption)) {
voteCountMap.put(selectedOption, voteCountMap.get(selectedOption) + 1);
totalVotes++;
}
}
// Convert map to list of VoteInfo
List<PollVotes.OptionCount> voteCounts = voteCountMap.entrySet().stream()
.map(entry -> new PollVotes.OptionCount(entry.getKey(), entry.getValue()))
.collect(Collectors.toList());
if (onlyCounts != null && onlyCounts) {
return new PollVotes(null, totalVotes, voteCounts);
} else {
return new PollVotes(votes, totalVotes, voteCounts);
}
} 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);
}
}
}

View File

@@ -0,0 +1,70 @@
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.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qortal.api.*;
import org.qortal.block.BlockChain;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import org.qortal.utils.Amounts;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.util.List;
@Path("/stats")
@Tag(name = "Stats")
public class StatsResource {
private static final Logger LOGGER = LogManager.getLogger(StatsResource.class);
@Context
HttpServletRequest request;
@GET
@Path("/supply/circulating")
@Operation(
summary = "Fetch circulating QORT supply",
responses = {
@ApiResponse(
description = "circulating supply of QORT",
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", format = "number"))
)
}
)
public BigDecimal circulatingSupply() {
long total = 0L;
try (final Repository repository = RepositoryManager.getRepository()) {
int currentHeight = repository.getBlockRepository().getBlockchainHeight();
List<BlockChain.RewardByHeight> rewardsByHeight = BlockChain.getInstance().getBlockRewardsByHeight();
int rewardIndex = rewardsByHeight.size() - 1;
BlockChain.RewardByHeight rewardInfo = rewardsByHeight.get(rewardIndex);
for (int height = currentHeight; height > 1; --height) {
if (height < rewardInfo.height) {
--rewardIndex;
rewardInfo = rewardsByHeight.get(rewardIndex);
}
total += rewardInfo.reward;
}
return Amounts.toBigDecimal(total);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
}

View File

@@ -215,10 +215,25 @@ public class TransactionsResource {
}
try (final Repository repository = RepositoryManager.getRepository()) {
if (repository.getBlockRepository().getHeightFromSignature(signature) == 0)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
// Check if the block exists in either the database or archive
int height = repository.getBlockRepository().getHeightFromSignature(signature);
if (height == 0) {
height = repository.getBlockArchiveRepository().getHeightFromSignature(signature);
if (height == 0) {
// Not found in either the database or archive
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN);
}
}
return repository.getBlockRepository().getTransactionsFromSignature(signature, limit, offset, reverse);
List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, height, height);
// Expand signatures to transactions
List<TransactionData> transactions = new ArrayList<>(signatures.size());
for (byte[] s : signatures) {
transactions.add(repository.getTransactionRepository().fromSignature(s));
}
return transactions;
} catch (ApiException e) {
throw e;
} catch (DataException e) {

View File

@@ -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,6 +46,7 @@ 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;
@@ -153,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(
@@ -183,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(

View File

@@ -157,10 +157,10 @@ public class RenderResource {
private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier,
String inPath, String secret58, String prefix, boolean usePrefix, boolean async, String theme) {
String inPath, String secret58, String prefix, boolean includeResourceIdInPrefix, boolean async, String theme) {
ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, identifier, inPath,
secret58, prefix, usePrefix, async, "render", request, response, context);
secret58, prefix, includeResourceIdInPrefix, async, "render", request, response, context);
if (theme != null) {
renderer.setTheme(theme);

View File

@@ -24,6 +24,7 @@ import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
import org.qortal.api.model.CrossChainOfferSummary;
import org.qortal.controller.Controller;
import org.qortal.controller.Synchronizer;
import org.qortal.controller.tradebot.TradeBot;
import org.qortal.crosschain.SupportedBlockchain;
import org.qortal.crosschain.ACCT;
import org.qortal.crosschain.AcctMode;
@@ -315,7 +316,7 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
throw new DataException("Couldn't fetch historic trades from repository");
for (ATStateData historicAtState : historicAtStates) {
CrossChainOfferSummary historicOfferSummary = produceSummary(repository, acct, historicAtState, null);
CrossChainOfferSummary historicOfferSummary = produceSummary(repository, acct, historicAtState, null, null);
if (!isHistoric.test(historicOfferSummary))
continue;
@@ -330,8 +331,10 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
}
}
private static CrossChainOfferSummary produceSummary(Repository repository, ACCT acct, ATStateData atState, Long timestamp) throws DataException {
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState);
private static CrossChainOfferSummary produceSummary(Repository repository, ACCT acct, ATStateData atState, CrossChainTradeData crossChainTradeData, Long timestamp) throws DataException {
if (crossChainTradeData == null) {
crossChainTradeData = acct.populateTradeData(repository, atState);
}
long atStateTimestamp;
@@ -346,9 +349,16 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
private static List<CrossChainOfferSummary> produceSummaries(Repository repository, ACCT acct, List<ATStateData> atStates, Long timestamp) throws DataException {
List<CrossChainOfferSummary> offerSummaries = new ArrayList<>();
for (ATStateData atState : atStates) {
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState);
for (ATStateData atState : atStates)
offerSummaries.add(produceSummary(repository, acct, atState, timestamp));
// Ignore trade if it has failed
if (TradeBot.getInstance().isFailedTrade(repository, crossChainTradeData)) {
continue;
}
offerSummaries.add(produceSummary(repository, acct, atState, crossChainTradeData, timestamp));
}
return offerSummaries;
}

View File

@@ -4,6 +4,7 @@ import org.qortal.arbitrary.exception.MissingDataException;
import org.qortal.arbitrary.ArbitraryDataFile.*;
import org.qortal.arbitrary.misc.Service;
import org.qortal.repository.DataException;
import org.qortal.utils.ArbitraryTransactionUtils;
import org.qortal.utils.NTP;
import java.io.IOException;
@@ -51,6 +52,9 @@ public class ArbitraryDataBuildQueueItem extends ArbitraryDataResource {
arbitraryDataReader.loadSynchronously(true);
} finally {
this.buildEndTimestamp = NTP.getTime();
// Update status after build
ArbitraryTransactionUtils.getStatus(service, resourceId, identifier, false, true);
}
}

View File

@@ -54,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();
@@ -69,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();

View File

@@ -79,7 +79,7 @@ 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;
@@ -90,7 +90,20 @@ public class ArbitraryDataFile {
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);
@@ -116,7 +129,7 @@ public class ArbitraryDataFile {
if (data == null) {
return null;
}
return new ArbitraryDataFile(data, signature);
return new ArbitraryDataFile(data, signature, true);
}
public static ArbitraryDataFile fromTransactionData(ArbitraryTransactionData transactionData) throws DataException {

View File

@@ -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 {

View File

@@ -9,7 +9,6 @@ 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.*;
@@ -19,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;
@@ -38,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 {
@@ -63,7 +62,11 @@ public class ArbitraryDataReader {
// The resource being read
ArbitraryDataResource arbitraryDataResource = null;
public ArbitraryDataReader(String resourceId, ResourceIdType resourceIdType, Service service, String identifier) {
// 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) throws DataException {
// Ensure names are always lowercase
if (resourceIdType == ResourceIdType.NAME) {
resourceId = resourceId.toLowerCase();
@@ -87,11 +90,16 @@ public class ArbitraryDataReader {
this.canRequestMissingFiles = true;
}
private Path buildWorkingPath() {
private Path buildWorkingPath() throws DataException {
// Use the user-specified temp dir, as it is deterministic, and is more likely to be located on reusable storage hardware
String baseDir = Settings.getInstance().getTempDataPath();
String identifier = this.identifier != null ? this.identifier : "default";
return Paths.get(baseDir, "reader", this.resourceIdType.toString(), this.resourceId, this.service.toString(), identifier);
try {
return Paths.get(baseDir, "reader", this.resourceIdType.toString(), StringUtils.sanitizeString(this.resourceId), this.service.toString(), StringUtils.sanitizeString(identifier));
} catch (InvalidPathException e) {
throw new DataException(String.format("Invalid path: %s", e.getMessage()));
}
}
public boolean isCachedDataAvailable() {
@@ -157,9 +165,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 {
@@ -173,6 +178,12 @@ public class ArbitraryDataReader {
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();
@@ -200,6 +211,7 @@ public class ArbitraryDataReader {
private void preExecute() throws DataException {
ArbitraryDataBuildManager.getInstance().setBuildInProgress(true);
this.checkEnabled();
this.createWorkingDirectory();
this.createUncompressedDirectory();
@@ -207,6 +219,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 {
@@ -215,18 +230,28 @@ 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);
} catch (IOException e) {
throw new DataException("Unable to create temp directory");
throw new DataException(String.format("Unable to create temp directory %s: %s", this.workingPath, e.getMessage()));
}
}
/**
* 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 {
@@ -306,7 +331,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));
}
}
@@ -371,9 +396,12 @@ public class ArbitraryDataReader {
// Load data file(s)
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData);
ArbitraryTransactionUtils.checkAndRelocateMiscFiles(transactionData);
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 {
@@ -446,6 +474,7 @@ public class ArbitraryDataReader {
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
@@ -480,7 +509,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);
@@ -516,10 +547,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);
}
}

View File

@@ -40,7 +40,7 @@ public class ArbitraryDataRenderer {
private String inPath;
private final String secret58;
private final String prefix;
private final boolean usePrefix;
private final boolean includeResourceIdInPrefix;
private final boolean async;
private final String qdnContext;
private final HttpServletRequest request;
@@ -48,7 +48,7 @@ public class ArbitraryDataRenderer {
private final ServletContext context;
public ArbitraryDataRenderer(String resourceId, ResourceIdType resourceIdType, Service service, String identifier,
String inPath, String secret58, String prefix, boolean usePrefix, boolean async, String qdnContext,
String inPath, String secret58, String prefix, boolean includeResourceIdInPrefix, boolean async, String qdnContext,
HttpServletRequest request, HttpServletResponse response, ServletContext context) {
this.resourceId = resourceId;
@@ -58,7 +58,7 @@ public class ArbitraryDataRenderer {
this.inPath = inPath;
this.secret58 = secret58;
this.prefix = prefix;
this.usePrefix = usePrefix;
this.includeResourceIdInPrefix = includeResourceIdInPrefix;
this.async = async;
this.qdnContext = qdnContext;
this.request = request;
@@ -67,8 +67,8 @@ public class ArbitraryDataRenderer {
}
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
@@ -76,9 +76,11 @@ public class ArbitraryDataRenderer {
return ArbitraryDataRenderer.getResponse(response, 500, "QDN is disabled in settings");
}
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(resourceId, resourceIdType, service, identifier);
arbitraryDataReader.setSecret58(secret58); // Optional, used for loading encrypted file hashes only
ArbitraryDataReader arbitraryDataReader;
try {
arbitraryDataReader = new ArbitraryDataReader(resourceId, resourceIdType, service, identifier);
arbitraryDataReader.setSecret58(secret58); // Optional, used for loading encrypted file hashes only
if (!arbitraryDataReader.isCachedDataAvailable()) {
// If async is requested, show a loading screen whilst build is in progress
if (async) {
@@ -126,7 +128,8 @@ public class ArbitraryDataRenderer {
try {
String filename = this.getFilename(unzippedPath, inPath);
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("/")) {
@@ -148,6 +151,7 @@ public class ArbitraryDataRenderer {
// Forward request to index file
filePath = indexPath;
filename = indexFile;
usingCustomRouting = true;
break;
}
}
@@ -157,7 +161,7 @@ public class ArbitraryDataRenderer {
if (HTMLParser.isHtmlFile(filename)) {
// HTML file - needs to be parsed
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);
HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, includeResourceIdInPrefix, data, qdnContext, service, identifier, theme, usingCustomRouting);
htmlParser.addAdditionalHeaderTags();
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));

View File

@@ -9,15 +9,16 @@ 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.data.arbitrary.ArbitraryResourceData;
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;
@@ -57,15 +58,39 @@ public class ArbitraryDataResource {
this.identifier = identifier;
}
public ArbitraryResourceStatus getStatus(boolean quick) {
// Calculate the chunk counts
// Avoid this for "quick" statuses, to speed things up
if (!quick) {
this.calculateChunkCounts();
public ArbitraryResourceStatus getStatusAndUpdateCache(boolean updateCache) {
ArbitraryResourceStatus arbitraryResourceStatus = null;
if (!this.exists) {
return new ArbitraryResourceStatus(Status.NOT_PUBLISHED, this.localChunkCount, this.totalChunkCount);
try (final Repository repository = RepositoryManager.getRepository()) {
arbitraryResourceStatus = this.getStatus(repository);
if (updateCache) {
// Update cache if possible
ArbitraryResourceStatus.Status status = arbitraryResourceStatus != null ? arbitraryResourceStatus.getStatus() : null;
ArbitraryResourceData arbitraryResourceData = new ArbitraryResourceData(this.service, this.resourceId, this.identifier);
repository.discardChanges();
repository.getArbitraryRepository().setStatus(arbitraryResourceData, status);
repository.saveChanges();
}
} catch (DataException e) {
LOGGER.info("Unable to update status cache for resource {}: {}", this.toString(), e.getMessage());
}
return arbitraryResourceStatus;
}
/**
* Get current status of resource
*
* @param repository
* @return the resource's status
*/
public ArbitraryResourceStatus getStatus(Repository repository) {
// Calculate the chunk counts
this.calculateChunkCounts(repository);
if (!this.exists) {
return new ArbitraryResourceStatus(Status.NOT_PUBLISHED, this.localChunkCount, this.totalChunkCount);
}
if (resourceIdType != ResourceIdType.NAME) {
@@ -74,8 +99,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);
}
@@ -87,18 +111,23 @@ public class ArbitraryDataResource {
}
// Firstly check the cache to see if it's already built
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(
resourceId, resourceIdType, service, identifier);
if (arbitraryDataReader.isCachedDataAvailable()) {
return new ArbitraryResourceStatus(Status.READY, this.localChunkCount, this.totalChunkCount);
try {
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(
resourceId, resourceIdType, service, identifier);
if (arbitraryDataReader.isCachedDataAvailable()) {
return new ArbitraryResourceStatus(Status.READY, this.localChunkCount, this.totalChunkCount);
}
} catch (DataException e) {
// Assume no usable data
return new ArbitraryResourceStatus(Status.PUBLISHED, this.localChunkCount, this.totalChunkCount);
}
// Check if we have all data locally for this resource
if (!this.allFilesDownloaded()) {
if (this.isDownloading()) {
if (!this.allFilesDownloaded(repository)) {
if (this.isDownloading(repository)) {
return new ArbitraryResourceStatus(Status.DOWNLOADING, this.localChunkCount, this.totalChunkCount);
}
else if (this.isDataPotentiallyAvailable()) {
else if (this.isDataPotentiallyAvailable(repository)) {
return new ArbitraryResourceStatus(Status.PUBLISHED, this.localChunkCount, this.totalChunkCount);
}
return new ArbitraryResourceStatus(Status.MISSING_DATA, this.localChunkCount, this.totalChunkCount);
@@ -140,9 +169,9 @@ public class ArbitraryDataResource {
return null;
}
public boolean delete(boolean deleteMetadata) {
public boolean delete(Repository repository, boolean deleteMetadata) {
try {
this.fetchTransactions();
this.fetchTransactions(repository);
if (this.transactions == null) {
return false;
}
@@ -151,6 +180,9 @@ public class ArbitraryDataResource {
for (ArbitraryTransactionData transactionData : transactionDataList) {
ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromTransactionData(transactionData);
if (arbitraryDataFile == null) {
continue;
}
// Delete any chunks or complete files from each transaction
arbitraryDataFile.deleteAll(deleteMetadata);
@@ -188,7 +220,7 @@ public class ArbitraryDataResource {
}
}
private boolean allFilesDownloaded() {
private boolean allFilesDownloaded(Repository repository) {
// Use chunk counts to speed things up if we can
if (this.localChunkCount != null && this.totalChunkCount != null &&
this.localChunkCount >= this.totalChunkCount) {
@@ -196,7 +228,7 @@ public class ArbitraryDataResource {
}
try {
this.fetchTransactions();
this.fetchTransactions(repository);
if (this.transactions == null) {
return false;
}
@@ -216,9 +248,14 @@ public class ArbitraryDataResource {
}
}
private void calculateChunkCounts() {
/**
* Calculate chunk counts of a resource
*
* @param repository optional - a new instance will be created if null
*/
private void calculateChunkCounts(Repository repository) {
try {
this.fetchTransactions();
this.fetchTransactions(repository);
if (this.transactions == null) {
this.exists = false;
this.localChunkCount = 0;
@@ -243,9 +280,9 @@ public class ArbitraryDataResource {
} catch (DataException e) {}
}
private boolean isRateLimited() {
private boolean isRateLimited(Repository repository) {
try {
this.fetchTransactions();
this.fetchTransactions(repository);
if (this.transactions == null) {
return true;
}
@@ -269,9 +306,9 @@ public class ArbitraryDataResource {
* This is only used to give an indication to the user of progress
* @return - whether data might be available on the network
*/
private boolean isDataPotentiallyAvailable() {
private boolean isDataPotentiallyAvailable(Repository repository) {
try {
this.fetchTransactions();
this.fetchTransactions(repository);
if (this.transactions == null) {
return false;
}
@@ -304,9 +341,9 @@ public class ArbitraryDataResource {
* This is only used to give an indication to the user of progress
* @return - whether we are trying to download the resource
*/
private boolean isDownloading() {
private boolean isDownloading(Repository repository) {
try {
this.fetchTransactions();
this.fetchTransactions(repository);
if (this.transactions == null) {
return false;
}
@@ -337,15 +374,19 @@ public class ArbitraryDataResource {
}
private void fetchTransactions() throws DataException {
/**
* Fetch relevant arbitrary transactions for resource
*
* @param repository
* @throws DataException
*/
private void fetchTransactions(Repository repository) throws DataException {
if (this.transactions != null && !this.transactions.isEmpty()) {
// Already fetched
return;
}
try (final Repository repository = RepositoryManager.getRepository()) {
try {
// Get the most recent PUT
ArbitraryTransactionData latestPut = repository.getArbitraryRepository()
.getLatestTransaction(this.resourceId, this.service, ArbitraryTransactionData.Method.PUT, this.identifier);

View File

@@ -117,8 +117,9 @@ public class ArbitraryDataTransactionBuilder {
}
private Method determineMethodAutomatically() throws DataException {
ArbitraryDataReader reader = new ArbitraryDataReader(this.name, ResourceIdType.NAME, this.service, this.identifier);
ArbitraryDataReader reader;
try {
reader = new ArbitraryDataReader(this.name, ResourceIdType.NAME, this.service, this.identifier);
reader.loadSynchronously(true);
} catch (Exception e) {
// Catch all exceptions if the existing resource cannot be loaded first time
@@ -191,6 +192,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;
}
@@ -297,6 +306,9 @@ public class ArbitraryDataTransactionBuilder {
}
private boolean isMetadataEqual(ArbitraryDataTransactionMetadata existingMetadata) {
if (existingMetadata == null) {
return !this.hasMetadata();
}
if (!Objects.equals(existingMetadata.getTitle(), this.title)) {
return false;
}
@@ -312,6 +324,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");

View File

@@ -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);
}

View File

@@ -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");
}

View File

@@ -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");
}

View File

@@ -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;
}
}

View File

@@ -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;
@@ -33,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");
}
@@ -216,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;
@@ -224,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) {
@@ -235,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) {

View File

@@ -67,9 +67,12 @@ public enum Category {
/**
* Same as valueOf() but with fallback to UNCATEGORIZED if there's no match
* @param name
* @return a Category (using UNCATEGORIZED if no match found)
* @return a Category (using UNCATEGORIZED if no match found), or null if null name passed
*/
public static Category uncategorizedValueOf(String name) {
if (name == null) {
return null;
}
try {
return Category.valueOf(name);
}

View File

@@ -9,7 +9,6 @@ import org.qortal.utils.FilesystemUtils;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
@@ -20,9 +19,9 @@ import static java.util.Arrays.stream;
import static java.util.stream.Collectors.toMap;
public enum Service {
AUTO_UPDATE(1, false, null, false, null),
ARBITRARY_DATA(100, false, null, false, null),
QCHAT_ATTACHMENT(120, true, 1024*1024L, true, 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) throws IOException {
ValidationResult superclassResult = super.validate(path);
@@ -47,7 +46,14 @@ public enum Service {
return ValidationResult.OK;
}
},
WEBSITE(200, true, null, false, null) {
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);
@@ -69,23 +75,30 @@ public enum Service {
return ValidationResult.MISSING_INDEX_FILE;
}
},
GIT_REPOSITORY(300, false, null, false, null),
IMAGE(400, true, 10*1024*1024L, true, null),
THUMBNAIL(410, true, 500*1024L, true, null),
QCHAT_IMAGE(420, true, 500*1024L, true, null),
VIDEO(500, false, null, true, null),
AUDIO(600, false, null, true, null),
QCHAT_AUDIO(610, true, 10*1024*1024L, true, null),
QCHAT_VOICE(620, true, 10*1024*1024L, true, null),
BLOG(700, false, null, false, null),
BLOG_POST(777, false, null, true, null),
BLOG_COMMENT(778, false, null, true, null),
DOCUMENT(800, false, null, true, null),
LIST(900, true, null, true, null),
PLAYLIST(910, true, null, true, null),
APP(1000, true, 50*1024*1024L, false, null),
METADATA(1100, false, null, true, null),
JSON(1110, true, 25*1024L, true, 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) throws IOException {
ValidationResult superclassResult = super.validate(path);
@@ -94,7 +107,7 @@ public enum Service {
}
// Require valid JSON
byte[] data = FilesystemUtils.getSingleFileContents(path);
byte[] data = FilesystemUtils.getSingleFileContents(path, 25*1024);
String json = new String(data, StandardCharsets.UTF_8);
try {
objectMapper.readTree(json);
@@ -104,7 +117,7 @@ public enum Service {
}
}
},
GIF_REPOSITORY(1200, true, 25*1024*1024L, false, null) {
GIF_REPOSITORY(1200, true, 25*1024*1024L, false, false, null) {
@Override
public ValidationResult validate(Path path) throws IOException {
ValidationResult superclassResult = super.validate(path);
@@ -139,12 +152,31 @@ 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())
@@ -153,11 +185,15 @@ public enum Service {
// For JSON validation
private static final ObjectMapper objectMapper = new ObjectMapper();
Service(int value, boolean requiresValidation, Long maxSize, boolean single, List<String> requiredKeys) {
private static final String encryptedDataPrefix = "qortalEncryptedData";
private static final String encryptedGroupDataPrefix = "qortalGroupEncryptedData";
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;
}
@@ -166,7 +202,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
@@ -181,6 +219,17 @@ public enum Service {
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) && !dataString.startsWith(encryptedGroupDataPrefix)) {
return ValidationResult.DATA_NOT_ENCRYPTED;
}
if (!this.isPrivate && (dataString.startsWith(encryptedDataPrefix) || dataString.startsWith(encryptedGroupDataPrefix))) {
return ValidationResult.DATA_ENCRYPTED;
}
}
// Validate required keys if needed
if (this.requiredKeys != null) {
if (data == null) {
@@ -199,7 +248,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) {
@@ -207,10 +261,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),
@@ -220,7 +305,9 @@ public enum Service {
INVALID_FILE_EXTENSION(6),
MISSING_DATA(7),
INVALID_FILE_COUNT(8),
INVALID_CONTENT(9);
INVALID_CONTENT(9),
DATA_NOT_ENCRYPTED(10),
DATA_ENCRYPTED(10);
public final int value;

View File

@@ -1686,12 +1686,14 @@ public class Block {
transactionData.getSignature());
this.repository.getBlockRepository().save(blockTransactionData);
// Update transaction's height in repository
// Update transaction's height in repository and local transactionData
transactionRepository.updateBlockHeight(transactionData.getSignature(), this.blockData.getHeight());
// Update local transactionData's height too
transaction.getTransactionData().setBlockHeight(this.blockData.getHeight());
// Update transaction's sequence in repository and local transactionData
transactionRepository.updateBlockSequence(transactionData.getSignature(), sequence);
transaction.getTransactionData().setBlockSequence(sequence);
// No longer unconfirmed
transactionRepository.confirmTransaction(transactionData.getSignature());
@@ -1778,6 +1780,9 @@ public class Block {
// Unset height
transactionRepository.updateBlockHeight(transactionData.getSignature(), null);
// Unset sequence
transactionRepository.updateBlockSequence(transactionData.getSignature(), null);
}
transactionRepository.deleteParticipants(transactionData);

View File

@@ -871,6 +871,9 @@ public class BlockChain {
BlockData orphanBlockData = repository.getBlockRepository().fromHeight(height);
while (height > targetHeight) {
if (Controller.isStopping()) {
return false;
}
LOGGER.info(String.format("Forcably orphaning block %d", height));
Block block = new Block(repository, orphanBlockData);

View File

@@ -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
}
}
}

View File

@@ -380,9 +380,13 @@ public class BlockMinter extends Thread {
parentSignatureForLastLowWeightBlock = null;
timeOfLastLowWeightBlock = null;
Long unconfirmedStartTime = NTP.getTime();
// Add unconfirmed transactions
addUnconfirmedTransactions(repository, newBlock);
LOGGER.info(String.format("Adding %d unconfirmed transactions took %d ms", newBlock.getTransactions().size(), (NTP.getTime()-unconfirmedStartTime)));
// Sign to create block's signature
newBlock.sign();
@@ -484,6 +488,9 @@ public class BlockMinter extends Thread {
// Sign to create block's signature, needed by Block.isValid()
newBlock.sign();
// User-defined limit per block
int limit = Settings.getInstance().getMaxTransactionsPerBlock();
// Attempt to add transactions until block is full, or we run out
// If a transaction makes the block invalid then skip it and it'll either expire or be in next block.
for (TransactionData transactionData : unconfirmedTransactions) {
@@ -496,6 +503,12 @@ public class BlockMinter extends Thread {
LOGGER.debug(() -> String.format("Skipping invalid transaction %s during block minting", Base58.encode(transactionData.getSignature())));
newBlock.deleteTransaction(transactionData);
}
// User-defined limit per block
List<Transaction> transactions = newBlock.getTransactions();
if (transactions != null && transactions.size() >= limit) {
break;
}
}
}

View File

@@ -47,6 +47,7 @@ import org.qortal.data.block.BlockData;
import org.qortal.data.block.BlockSummaryData;
import org.qortal.data.naming.NameData;
import org.qortal.data.network.PeerData;
import org.qortal.data.transaction.ArbitraryTransactionData;
import org.qortal.data.transaction.ChatTransactionData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.event.Event;
@@ -400,10 +401,14 @@ public class Controller extends Thread {
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(getRepositoryUrl());
RepositoryManager.setRepositoryFactory(repositoryFactory);
RepositoryManager.setRequestedCheckpoint(Boolean.TRUE);
}
catch (DataException e) {
// If exception has no cause then repository is in use by some other process.
if (e.getCause() == null) {
try (final Repository repository = RepositoryManager.getRepository()) {
RepositoryManager.rebuildTransactionSequences(repository);
ArbitraryDataCacheManager.getInstance().buildArbitraryResourcesCache(repository, false);
}
} catch (DataException e) {
// If exception has no cause or message then repository is in use by some other process.
if (e.getCause() == null && e.getMessage() == null) {
LOGGER.info("Repository in use by another process?");
Gui.getInstance().fatalError("Repository issue", "Repository in use by another process?");
} else {
@@ -437,6 +442,26 @@ public class Controller extends Thread {
}
}
try (Repository repository = RepositoryManager.getRepository()) {
if (RepositoryManager.needsTransactionSequenceRebuild(repository)) {
// Don't allow the node to start if transaction sequences haven't been built yet
// This is needed to handle a case when bootstrapping
LOGGER.error("Database upgrade needed. Please restart the core to complete the upgrade process.");
Gui.getInstance().fatalError("Database upgrade needed", "Please restart the core to complete the upgrade process.");
return;
}
if (ArbitraryDataCacheManager.getInstance().needsArbitraryResourcesCacheRebuild(repository)) {
// Don't allow the node to start if arbitrary resources cache hasn't been built yet
// This is needed to handle a case when bootstrapping
LOGGER.error("Database upgrade needed. Please restart the core to complete the upgrade process.");
Gui.getInstance().fatalError("Database upgrade needed", "Please restart the core to complete the upgrade process.");
return;
}
} catch (DataException e) {
LOGGER.error("Error checking transaction sequences in repository", e);
return;
}
// Import current trade bot states and minting accounts if they exist
Controller.importRepositoryData();
@@ -480,6 +505,7 @@ public class Controller extends Thread {
LOGGER.info("Starting arbitrary-transaction controllers");
ArbitraryDataManager.getInstance().start();
ArbitraryDataFileManager.getInstance().start();
ArbitraryDataCacheManager.getInstance().start();
ArbitraryDataBuildManager.getInstance().start();
ArbitraryDataCleanupManager.getInstance().start();
ArbitraryDataStorageManager.getInstance().start();
@@ -891,6 +917,7 @@ public class Controller extends Thread {
if (now >= transaction.getDeadline()) {
LOGGER.debug(() -> String.format("Deleting expired, unconfirmed transaction %s", Base58.encode(transactionData.getSignature())));
repository.getTransactionRepository().delete(transactionData);
this.onExpiredTransaction(transactionData);
deletedCount++;
}
}
@@ -933,6 +960,7 @@ public class Controller extends Thread {
LOGGER.info("Shutting down arbitrary-transaction controllers");
ArbitraryDataManager.getInstance().shutdown();
ArbitraryDataFileManager.getInstance().shutdown();
ArbitraryDataCacheManager.getInstance().shutdown();
ArbitraryDataBuildManager.getInstance().shutdown();
ArbitraryDataCleanupManager.getInstance().shutdown();
ArbitraryDataStorageManager.getInstance().shutdown();
@@ -1203,6 +1231,21 @@ public class Controller extends Thread {
});
}
/**
* Callback for when we've deleted an expired, unconfirmed transaction.
* <p>
* @implSpec performs actions in a new thread
*/
public void onExpiredTransaction(TransactionData transactionData) {
this.callbackExecutor.execute(() -> {
// If this is an ARBITRARY transaction, we may need to update the cache
if (transactionData.getType() == TransactionType.ARBITRARY) {
ArbitraryDataManager.getInstance().onExpiredArbitraryTransaction((ArbitraryTransactionData)transactionData);
}
});
}
public void onPeerHandshakeCompleted(Peer peer) {
// Only send if outbound
if (peer.isOutbound()) {
@@ -1262,13 +1305,6 @@ public class Controller extends Thread {
TransactionImporter.getInstance().onNetworkTransactionSignaturesMessage(peer, message);
break;
case GET_ONLINE_ACCOUNTS:
case ONLINE_ACCOUNTS:
case GET_ONLINE_ACCOUNTS_V2:
case ONLINE_ACCOUNTS_V2:
// No longer supported - to be eventually removed
break;
case GET_ONLINE_ACCOUNTS_V3:
OnlineAccountsManager.getInstance().onNetworkGetOnlineAccountsV3Message(peer, message);
break;

View File

@@ -0,0 +1,74 @@
package org.qortal.controller;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qortal.api.DevProxyService;
import org.qortal.repository.DataException;
import org.qortal.settings.Settings;
public class DevProxyManager {
protected static final Logger LOGGER = LogManager.getLogger(DevProxyManager.class);
private static DevProxyManager instance;
private boolean running = false;
private String sourceHostAndPort = "127.0.0.1:5173"; // Default for React/Vite
private DevProxyManager() {
}
public static DevProxyManager getInstance() {
if (instance == null)
instance = new DevProxyManager();
return instance;
}
public void start() throws DataException {
synchronized(this) {
if (this.running) {
// Already running
return;
}
LOGGER.info(String.format("Starting developer proxy service on port %d", Settings.getInstance().getDevProxyPort()));
DevProxyService devProxyService = DevProxyService.getInstance();
devProxyService.start();
this.running = true;
}
}
public void stop() {
synchronized(this) {
if (!this.running) {
// Not running
return;
}
LOGGER.info(String.format("Shutting down developer proxy service"));
DevProxyService devProxyService = DevProxyService.getInstance();
devProxyService.stop();
this.running = false;
}
}
public void setSourceHostAndPort(String sourceHostAndPort) {
this.sourceHostAndPort = sourceHostAndPort;
}
public String getSourceHostAndPort() {
return this.sourceHostAndPort;
}
public Integer getPort() {
return Settings.getInstance().getDevProxyPort();
}
public boolean isRunning() {
return this.running;
}
}

View File

@@ -414,7 +414,7 @@ public class OnlineAccountsManager {
boolean isSuperiorEntry = isOnlineAccountsDataSuperior(onlineAccountData);
if (isSuperiorEntry)
// Remove existing inferior entry so it can be re-added below (it's likely the existing copy is missing a nonce value)
onlineAccounts.remove(onlineAccountData);
onlineAccounts.removeIf(a -> Objects.equals(a.getPublicKey(), onlineAccountData.getPublicKey()));
boolean isNewEntry = onlineAccounts.add(onlineAccountData);
@@ -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;
}

View File

@@ -187,7 +187,7 @@ public class PirateChainWalletController extends Thread {
// Check its status
ArbitraryResourceStatus status = ArbitraryTransactionUtils.getStatus(
t.getService(), t.getName(), t.getIdentifier(), false);
t.getService(), t.getName(), t.getIdentifier(), false, true);
if (status.getStatus() != ArbitraryResourceStatus.Status.READY) {
LOGGER.info("Not ready yet: {}", status.getTitle());

View File

@@ -5,13 +5,17 @@ import org.apache.logging.log4j.Logger;
import org.qortal.arbitrary.ArbitraryDataBuildQueueItem;
import org.qortal.arbitrary.exception.MissingDataException;
import org.qortal.controller.Controller;
import org.qortal.data.arbitrary.ArbitraryResourceStatus;
import org.qortal.repository.DataException;
import org.qortal.utils.ArbitraryTransactionUtils;
import org.qortal.utils.NTP;
import java.io.IOException;
import java.util.Comparator;
import java.util.Map;
import static org.qortal.data.arbitrary.ArbitraryResourceStatus.Status.*;
public class ArbitraryDataBuilderThread implements Runnable {
@@ -69,6 +73,14 @@ public class ArbitraryDataBuilderThread implements Runnable {
continue;
}
// Get status before build
ArbitraryResourceStatus arbitraryResourceStatus = ArbitraryTransactionUtils.getStatus(queueItem.getService(), queueItem.getResourceId(), queueItem.getIdentifier(), false, true);
if (arbitraryResourceStatus.getStatus() == NOT_PUBLISHED) {
// No point in building a non-existent resource
this.removeFromQueue(queueItem);
continue;
}
// Set the start timestamp, to prevent other threads from building it at the same time
queueItem.prepareForBuild();
}

View File

@@ -0,0 +1,236 @@
package org.qortal.controller.arbitrary;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qortal.api.resource.TransactionsResource;
import org.qortal.controller.Controller;
import org.qortal.data.arbitrary.ArbitraryResourceData;
import org.qortal.data.transaction.ArbitraryTransactionData;
import org.qortal.gui.SplashFrame;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import org.qortal.settings.Settings;
import org.qortal.transaction.ArbitraryTransaction;
import org.qortal.transaction.Transaction;
import org.qortal.utils.Base58;
import java.util.*;
public class ArbitraryDataCacheManager extends Thread {
private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataCacheManager.class);
private static ArbitraryDataCacheManager instance;
private volatile boolean isStopping = false;
/** Queue of arbitrary transactions that require cache updates */
private final List<ArbitraryTransactionData> updateQueue = Collections.synchronizedList(new ArrayList<>());
public static synchronized ArbitraryDataCacheManager getInstance() {
if (instance == null) {
instance = new ArbitraryDataCacheManager();
}
return instance;
}
@Override
public void run() {
Thread.currentThread().setName("Arbitrary Data Cache Manager");
try {
while (!Controller.isStopping()) {
Thread.sleep(500L);
// Process queue
processResourceQueue();
}
} catch (InterruptedException e) {
// Fall through to exit thread
}
// Clear queue before terminating thread
processResourceQueue();
}
public void shutdown() {
isStopping = true;
this.interrupt();
}
private void processResourceQueue() {
if (this.updateQueue.isEmpty()) {
// Nothing to do
return;
}
try (final Repository repository = RepositoryManager.getRepository()) {
// Take a snapshot of resourceQueue, so we don't need to lock it while processing
List<ArbitraryTransactionData> resourceQueueCopy = List.copyOf(this.updateQueue);
for (ArbitraryTransactionData transactionData : resourceQueueCopy) {
// Best not to return when controller is stopping, as ideally we need to finish processing
LOGGER.debug(() -> String.format("Processing transaction %.8s in arbitrary resource queue...", Base58.encode(transactionData.getSignature())));
// Remove from the queue regardless of outcome
this.updateQueue.remove(transactionData);
// Update arbitrary resource caches
try {
ArbitraryTransaction arbitraryTransaction = new ArbitraryTransaction(repository, transactionData);
arbitraryTransaction.updateArbitraryResourceCache(repository);
arbitraryTransaction.updateArbitraryMetadataCache(repository);
repository.saveChanges();
// Update status as separate commit, as this is more prone to failure
arbitraryTransaction.updateArbitraryResourceStatus(repository);
repository.saveChanges();
LOGGER.debug(() -> String.format("Finished processing transaction %.8s in arbitrary resource queue...", Base58.encode(transactionData.getSignature())));
} catch (DataException e) {
repository.discardChanges();
LOGGER.error("Repository issue while updating arbitrary resource caches", e);
}
}
} catch (DataException e) {
LOGGER.error("Repository issue while processing arbitrary resource cache updates", e);
}
}
public void addToUpdateQueue(ArbitraryTransactionData transactionData) {
this.updateQueue.add(transactionData);
LOGGER.debug(() -> String.format("Transaction %.8s added to queue", Base58.encode(transactionData.getSignature())));
}
public boolean needsArbitraryResourcesCacheRebuild(Repository repository) throws DataException {
// Check if we have an entry in the cache for the oldest ARBITRARY transaction with a name
List<ArbitraryTransactionData> oldestCacheableTransactions = repository.getArbitraryRepository().getArbitraryTransactions(true, 1, 0, false);
if (oldestCacheableTransactions == null || oldestCacheableTransactions.isEmpty()) {
// No relevant arbitrary transactions yet on this chain
LOGGER.debug("No relevant arbitrary transactions exist to build cache from");
return false;
}
// We have an arbitrary transaction, so check if it's in the cache
ArbitraryTransactionData txn = oldestCacheableTransactions.get(0);
ArbitraryResourceData cachedResource = repository.getArbitraryRepository().getArbitraryResource(txn.getService(), txn.getName(), txn.getIdentifier());
if (cachedResource != null) {
// Earliest resource exists in the cache, so assume it has been built.
// We avoid checkpointing and prevent the node from starting up in the case of a rebuild failure, so
// we shouldn't ever be left in a partially rebuilt state.
LOGGER.debug("Arbitrary resources cache already built");
return false;
}
return true;
}
public boolean buildArbitraryResourcesCache(Repository repository, boolean forceRebuild) throws DataException {
if (Settings.getInstance().isLite()) {
// Lite nodes have no blockchain
return false;
}
try {
// Skip if already built
if (!needsArbitraryResourcesCacheRebuild(repository) && !forceRebuild) {
LOGGER.debug("Arbitrary resources cache already built");
return false;
}
LOGGER.info("Building arbitrary resources cache...");
SplashFrame.getInstance().updateStatus("Building QDN cache - please wait...");
final int batchSize = 100;
int offset = 0;
// Loop through all ARBITRARY transactions, and determine latest state
while (!Controller.isStopping()) {
LOGGER.info("Fetching arbitrary transactions {} - {}", offset, offset+batchSize-1);
List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, List.of(Transaction.TransactionType.ARBITRARY), null, null, null, TransactionsResource.ConfirmationStatus.BOTH, batchSize, offset, false);
if (signatures.isEmpty()) {
// Complete
break;
}
// Expand signatures to transactions
for (byte[] signature : signatures) {
ArbitraryTransactionData transactionData = (ArbitraryTransactionData) repository
.getTransactionRepository().fromSignature(signature);
if (transactionData.getService() == null) {
// Unsupported service - ignore this resource
continue;
}
// Update arbitrary resource caches
ArbitraryTransaction arbitraryTransaction = new ArbitraryTransaction(repository, transactionData);
arbitraryTransaction.updateArbitraryResourceCache(repository);
arbitraryTransaction.updateArbitraryMetadataCache(repository);
repository.saveChanges();
}
offset += batchSize;
}
// Now refresh all statuses
refreshArbitraryStatuses(repository);
LOGGER.info("Completed build of arbitrary resources cache.");
return true;
}
catch (DataException e) {
LOGGER.info("Unable to build arbitrary resources cache: {}. The database may have been left in an inconsistent state.", e.getMessage());
// Throw an exception so that the node startup is halted, allowing for a retry next time.
repository.discardChanges();
throw new DataException("Build of arbitrary resources cache failed.");
}
}
private boolean refreshArbitraryStatuses(Repository repository) throws DataException {
try {
LOGGER.info("Refreshing arbitrary resource statuses for locally hosted transactions...");
SplashFrame.getInstance().updateStatus("Refreshing statuses - please wait...");
final int batchSize = 100;
int offset = 0;
// Loop through all ARBITRARY transactions, and determine latest state
while (!Controller.isStopping()) {
LOGGER.info("Fetching hosted transactions {} - {}", offset, offset+batchSize-1);
List<ArbitraryTransactionData> hostedTransactions = ArbitraryDataStorageManager.getInstance().listAllHostedTransactions(repository, batchSize, offset);
if (hostedTransactions.isEmpty()) {
// Complete
break;
}
// Loop through hosted transactions
for (ArbitraryTransactionData transactionData : hostedTransactions) {
// Determine status and update cache
ArbitraryTransaction arbitraryTransaction = new ArbitraryTransaction(repository, transactionData);
arbitraryTransaction.updateArbitraryResourceStatus(repository);
repository.saveChanges();
}
offset += batchSize;
}
LOGGER.info("Completed refresh of arbitrary resource statuses.");
return true;
}
catch (DataException e) {
LOGGER.info("Unable to refresh arbitrary resource statuses: {}. The database may have been left in an inconsistent state.", e.getMessage());
// Throw an exception so that the node startup is halted, allowing for a retry next time.
repository.discardChanges();
throw new DataException("Refresh of arbitrary resource statuses failed.");
}
}
}

View File

@@ -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;
@@ -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");
}

View File

@@ -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;
}
@@ -503,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) {
@@ -682,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

View File

@@ -146,7 +146,7 @@ public class ArbitraryDataFileManager extends Thread {
if (!arbitraryDataFileRequests.containsKey(Base58.encode(hash))) {
LOGGER.debug("Requesting data file {} from peer {}", hash58, peer);
Long startTime = NTP.getTime();
ArbitraryDataFile receivedArbitraryDataFile = fetchArbitraryDataFile(peer, null, signature, hash, null);
ArbitraryDataFile receivedArbitraryDataFile = fetchArbitraryDataFile(peer, null, arbitraryTransactionData, signature, hash, null);
Long endTime = NTP.getTime();
if (receivedArbitraryDataFile != null) {
LOGGER.debug("Received data file {} from peer {}. Time taken: {} ms", receivedArbitraryDataFile.getHash58(), peer, (endTime-startTime));
@@ -191,7 +191,7 @@ public class ArbitraryDataFileManager extends Thread {
return receivedAtLeastOneFile;
}
private ArbitraryDataFile fetchArbitraryDataFile(Peer peer, Peer requestingPeer, byte[] signature, byte[] hash, Message originalMessage) throws DataException {
private ArbitraryDataFile fetchArbitraryDataFile(Peer peer, Peer requestingPeer, ArbitraryTransactionData arbitraryTransactionData, byte[] signature, byte[] hash, Message originalMessage) throws DataException {
ArbitraryDataFile existingFile = ArbitraryDataFile.fromHash(hash, signature);
boolean fileAlreadyExists = existingFile.exists();
String hash58 = Base58.encode(hash);
@@ -231,6 +231,11 @@ public class ArbitraryDataFileManager extends Thread {
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, new ArbitraryDataFileMessage(signature, arbitraryDataFile), originalMessage);
@@ -245,6 +250,13 @@ public class ArbitraryDataFileManager extends Thread {
}
}
// If this is a metadata file then we need to update the cache
if (arbitraryTransactionData != null && arbitraryTransactionData.getMetadataHash() != null) {
if (Arrays.equals(arbitraryTransactionData.getMetadataHash(), hash)) {
ArbitraryDataCacheManager.getInstance().addToUpdateQueue(arbitraryTransactionData);
}
}
return arbitraryDataFile;
}
@@ -580,7 +592,9 @@ public class ArbitraryDataFileManager extends Thread {
// Forward the message to this peer
LOGGER.debug("Asking peer {} for hash {}", peerToAsk, hash58);
this.fetchArbitraryDataFile(peerToAsk, peer, signature, hash, message);
// No need to pass arbitraryTransactionData below because this is only used for metadata caching,
// and metadata isn't retained when relaying.
this.fetchArbitraryDataFile(peerToAsk, peer, null, signature, hash, message);
}
else {
LOGGER.debug("Peer {} not found in relay info", peer);

View File

@@ -14,9 +14,9 @@ import org.qortal.arbitrary.ArbitraryDataResource;
import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata;
import org.qortal.arbitrary.misc.Service;
import org.qortal.controller.Controller;
import org.qortal.data.arbitrary.ArbitraryResourceData;
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 +27,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 +173,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;
}
@@ -539,6 +540,17 @@ public class ArbitraryDataManager extends Thread {
return true;
}
public void onExpiredArbitraryTransaction(ArbitraryTransactionData arbitraryTransactionData) {
if (arbitraryTransactionData.getName() == null) {
// No name, so we don't care about this transaction
return;
}
// Add to queue for update/deletion
ArbitraryDataCacheManager.getInstance().addToUpdateQueue(arbitraryTransactionData);
}
public int getPowDifficulty() {
return this.powDifficulty;
}

View File

@@ -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;
@@ -61,6 +57,8 @@ public class ArbitraryDataStorageManager extends Thread {
* This must be higher than STORAGE_FULL_THRESHOLD in order to avoid a fetch/delete loop. */
public static final double DELETION_THRESHOLD = 0.98f; // 98%
private static final long PER_NAME_STORAGE_MULTIPLIER = 4L;
public ArbitraryDataStorageManager() {
}
@@ -135,11 +133,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:
@@ -188,14 +186,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;
@@ -235,7 +233,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) {
@@ -253,22 +251,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) {
@@ -508,12 +490,17 @@ public class ArbitraryDataStorageManager extends Thread {
return false;
}
if (Settings.getInstance().getStoragePolicy() == StoragePolicy.ALL) {
// Using storage policy ALL, so don't limit anything per name
return true;
}
if (name == null) {
// This transaction doesn't have a name, so fall back to total space limitations
return true;
}
int followedNamesCount = this.followedNamesCount();
int followedNamesCount = ListUtils.followedNamesCount();
if (followedNamesCount == 0) {
// Not following any names, so we have space
return true;
@@ -543,14 +530,16 @@ 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);
}
double maxStorageCapacity = (double)this.storageCapacity * threshold;
long maxStoragePerName = (long)(maxStorageCapacity / (double)followedNamesCount);
// Some names won't need/use much space, so give all names a 4x multiplier to compensate
long maxStoragePerName = (long)(maxStorageCapacity / (double)followedNamesCount) * PER_NAME_STORAGE_MULTIPLIER;
return maxStoragePerName;
}

View File

@@ -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;
}
}
@@ -312,41 +320,46 @@ public class ArbitraryMetadataManager {
return;
}
// Update requests map to reflect that we've received all chunks
// Update requests map to reflect that we've received this metadata
Triple<String, Peer, Long> newEntry = new Triple<>(null, null, request.getC());
arbitraryMetadataRequests.put(message.getId(), newEntry);
ArbitraryTransactionData arbitraryTransactionData = null;
// Forwarding
if (isRelayRequest && Settings.getInstance().isRelayModeEnabled()) {
// Get transaction info
try (final Repository repository = RepositoryManager.getRepository()) {
TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature);
if (!(transactionData instanceof ArbitraryTransactionData))
return;
arbitraryTransactionData = (ArbitraryTransactionData) transactionData;
} catch (DataException e) {
LOGGER.error(String.format("Repository issue while finding arbitrary transaction metadata for peer %s", peer), e);
// Get transaction info
try (final Repository repository = RepositoryManager.getRepository()) {
TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature);
if (!(transactionData instanceof ArbitraryTransactionData)) {
return;
}
ArbitraryTransactionData arbitraryTransactionData = (ArbitraryTransactionData) transactionData;
// Check if the name is blocked
boolean isBlocked = (arbitraryTransactionData == null || ArbitraryDataStorageManager.getInstance().isNameBlocked(arbitraryTransactionData.getName()));
if (!isBlocked) {
Peer requestingPeer = request.getB();
if (requestingPeer != null) {
// Forwarding
if (isRelayRequest && Settings.getInstance().isRelayModeEnabled()) {
ArbitraryMetadataMessage forwardArbitraryMetadataMessage = new ArbitraryMetadataMessage(signature, arbitraryMetadataMessage.getArbitraryMetadataFile());
forwardArbitraryMetadataMessage.setId(arbitraryMetadataMessage.getId());
// Check if the name is blocked
boolean isBlocked = (arbitraryTransactionData == null || ListUtils.isNameBlocked(arbitraryTransactionData.getName()));
if (!isBlocked) {
Peer requestingPeer = request.getB();
if (requestingPeer != null) {
// Forward to requesting peer
LOGGER.debug("Forwarding metadata to requesting peer: {}", requestingPeer);
if (!requestingPeer.sendMessage(forwardArbitraryMetadataMessage)) {
requestingPeer.disconnect("failed to forward arbitrary metadata");
ArbitraryMetadataMessage forwardArbitraryMetadataMessage = new ArbitraryMetadataMessage(signature, arbitraryMetadataMessage.getArbitraryMetadataFile());
forwardArbitraryMetadataMessage.setId(arbitraryMetadataMessage.getId());
// Forward to requesting peer
LOGGER.debug("Forwarding metadata to requesting peer: {}", requestingPeer);
if (!requestingPeer.sendMessage(forwardArbitraryMetadataMessage)) {
requestingPeer.disconnect("failed to forward arbitrary metadata");
}
}
}
}
// Add to resource queue to update arbitrary resource caches
if (arbitraryTransactionData != null) {
ArbitraryDataCacheManager.getInstance().addToUpdateQueue(arbitraryTransactionData);
}
} catch (DataException e) {
LOGGER.error(String.format("Repository issue while saving arbitrary transaction metadata from peer %s", peer), e);
}
}
@@ -420,7 +433,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

View File

@@ -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 {
@@ -23,21 +25,14 @@ public class NamesDatabaseIntegrityCheck {
TransactionType.REGISTER_NAME,
TransactionType.UPDATE_NAME,
TransactionType.BUY_NAME,
TransactionType.SELL_NAME
TransactionType.SELL_NAME,
TransactionType.CANCEL_SELL_NAME
);
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 +41,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 +64,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()));
}
}
@@ -354,8 +342,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;
}
@@ -419,4 +407,67 @@ public class NamesDatabaseIntegrityCheck {
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()));
}});
}
}

View File

@@ -10,6 +10,7 @@ import org.apache.logging.log4j.Logger;
import org.bitcoinj.core.ECKey;
import org.qortal.account.PrivateKeyAccount;
import org.qortal.api.model.crosschain.TradeBotCreateRequest;
import org.qortal.api.resource.TransactionsResource;
import org.qortal.controller.Controller;
import org.qortal.controller.Synchronizer;
import org.qortal.controller.tradebot.AcctTradeBot.ResponseResult;
@@ -19,6 +20,7 @@ import org.qortal.data.at.ATData;
import org.qortal.data.crosschain.CrossChainTradeData;
import org.qortal.data.crosschain.TradeBotData;
import org.qortal.data.network.TradePresenceData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.event.Event;
import org.qortal.event.EventBus;
import org.qortal.event.Listener;
@@ -33,6 +35,7 @@ import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import org.qortal.repository.hsqldb.HSQLDBImportExport;
import org.qortal.settings.Settings;
import org.qortal.transaction.Transaction;
import org.qortal.utils.ByteArray;
import org.qortal.utils.NTP;
@@ -113,6 +116,9 @@ public class TradeBot implements Listener {
private Map<ByteArray, TradePresenceData> safeAllTradePresencesByPubkey = Collections.emptyMap();
private long nextTradePresenceBroadcastTimestamp = 0L;
private Map<String, Long> failedTrades = new HashMap<>();
private Map<String, Long> validTrades = new HashMap<>();
private TradeBot() {
EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event));
}
@@ -674,6 +680,78 @@ public class TradeBot implements Listener {
});
}
/** Removes any trades that have had multiple failures */
public List<CrossChainTradeData> removeFailedTrades(Repository repository, List<CrossChainTradeData> crossChainTrades) {
Long now = NTP.getTime();
if (now == null) {
return crossChainTrades;
}
List<CrossChainTradeData> updatedCrossChainTrades = new ArrayList<>(crossChainTrades);
int getMaxTradeOfferAttempts = Settings.getInstance().getMaxTradeOfferAttempts();
for (CrossChainTradeData crossChainTradeData : crossChainTrades) {
// We only care about trades in the OFFERING state
if (crossChainTradeData.mode != AcctMode.OFFERING) {
failedTrades.remove(crossChainTradeData.qortalAtAddress);
validTrades.remove(crossChainTradeData.qortalAtAddress);
continue;
}
// Return recently cached values if they exist
Long failedTimestamp = failedTrades.get(crossChainTradeData.qortalAtAddress);
if (failedTimestamp != null && now - failedTimestamp < 60 * 60 * 1000L) {
updatedCrossChainTrades.remove(crossChainTradeData);
//LOGGER.info("Removing cached failed trade AT {}", crossChainTradeData.qortalAtAddress);
continue;
}
Long validTimestamp = validTrades.get(crossChainTradeData.qortalAtAddress);
if (validTimestamp != null && now - validTimestamp < 60 * 60 * 1000L) {
//LOGGER.info("NOT removing cached valid trade AT {}", crossChainTradeData.qortalAtAddress);
continue;
}
try {
List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, Arrays.asList(Transaction.TransactionType.MESSAGE), null, null, crossChainTradeData.qortalCreatorTradeAddress, TransactionsResource.ConfirmationStatus.CONFIRMED, null, null, null);
if (signatures.size() < getMaxTradeOfferAttempts) {
// Less than 3 (or user-specified number of) MESSAGE transactions relate to this trade, so assume it is ok
validTrades.put(crossChainTradeData.qortalAtAddress, now);
continue;
}
List<TransactionData> transactions = new ArrayList<>(signatures.size());
for (byte[] signature : signatures) {
transactions.add(repository.getTransactionRepository().fromSignature(signature));
}
transactions.sort(Transaction.getDataComparator());
// Get timestamp of the first MESSAGE transaction
long firstMessageTimestamp = transactions.get(0).getTimestamp();
// Treat as failed if first buy attempt was more than 60 mins ago (as it's still in the OFFERING state)
boolean isFailed = (now - firstMessageTimestamp > 60*60*1000L);
if (isFailed) {
failedTrades.put(crossChainTradeData.qortalAtAddress, now);
updatedCrossChainTrades.remove(crossChainTradeData);
}
else {
validTrades.put(crossChainTradeData.qortalAtAddress, now);
}
} catch (DataException e) {
LOGGER.info("Unable to determine failed state of AT {}", crossChainTradeData.qortalAtAddress);
continue;
}
}
return updatedCrossChainTrades;
}
public boolean isFailedTrade(Repository repository, CrossChainTradeData crossChainTradeData) {
List<CrossChainTradeData> results = removeFailedTrades(repository, Arrays.asList(crossChainTradeData));
return results.isEmpty();
}
private long generateExpiry(long timestamp) {
return ((timestamp - 1) / EXPIRY_ROUNDING) * EXPIRY_ROUNDING + PRESENCE_LIFETIME;
}

View File

@@ -28,7 +28,7 @@ public abstract class TrustlessSSLSocketFactory {
private static final SSLContext sc;
static {
try {
sc = SSLContext.getInstance("SSL");
sc = SSLContext.getInstance("TLSv1.3");
sc.init(null, TRUSTLESS_MANAGER, new java.security.SecureRandom());
} catch (Exception e) {
throw new RuntimeException(e);

View File

@@ -6,8 +6,10 @@ import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import java.util.Objects;
import static org.qortal.data.arbitrary.ArbitraryResourceStatus.Status;
@XmlAccessorType(XmlAccessType.FIELD)
public class ArbitraryResourceInfo {
public class ArbitraryResourceData {
public String name;
public Service service;
@@ -15,11 +17,21 @@ public class ArbitraryResourceInfo {
public ArbitraryResourceStatus status;
public ArbitraryResourceMetadata metadata;
public Long size;
public Integer size;
public Long created;
public Long updated;
public ArbitraryResourceInfo() {
public ArbitraryResourceData() {
}
public ArbitraryResourceData(Service service, String name, String identifier) {
if (identifier == null) {
identifier = "default";
}
this.service = service;
this.name = name;
this.identifier = identifier;
}
@Override
@@ -27,15 +39,24 @@ public class ArbitraryResourceInfo {
return String.format("%s %s %s", name, service, identifier);
}
public void setStatus(Status status) {
if (status == null) {
this.status = null;
}
else {
this.status = new ArbitraryResourceStatus(status);
}
}
@Override
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof ArbitraryResourceInfo))
if (!(o instanceof ArbitraryResourceData))
return false;
ArbitraryResourceInfo other = (ArbitraryResourceInfo) o;
ArbitraryResourceData other = (ArbitraryResourceData) o;
return Objects.equals(this.name, other.name) &&
Objects.equals(this.service, other.service) &&

View File

@@ -18,6 +18,9 @@ public class ArbitraryResourceMetadata {
private List<String> files;
private String mimeType;
// Only included when updating database
private ArbitraryResourceData arbitraryResourceData;
public ArbitraryResourceMetadata() {
}
@@ -60,4 +63,52 @@ public class ArbitraryResourceMetadata {
public List<String> getFiles() {
return this.files;
}
public void setTitle(String title) {
this.title = title;
}
public String getTitle() {
return this.title;
}
public void setDescription(String description) {
this.description = description;
}
public String getDescription() {
return this.description;
}
public void setTags(List<String> tags) {
this.tags = tags;
}
public List<String> getTags() {
return this.tags;
}
public void setCategory(Category category) {
this.category = category;
// Also set categoryName
if (category != null) {
this.categoryName = category.getName();
}
}
public Category getCategory() {
return this.category;
}
public boolean hasMetadata() {
return title != null || description != null || tags != null || category != null || files != null || mimeType != null;
}
public void setArbitraryResourceData(ArbitraryResourceData arbitraryResourceData) {
this.arbitraryResourceData = arbitraryResourceData;
}
public ArbitraryResourceData getArbitraryResourceData() {
return this.arbitraryResourceData;
}
}

View File

@@ -9,7 +9,7 @@ import java.util.List;
public class ArbitraryResourceNameInfo {
public String name;
public List<ArbitraryResourceInfo> resources = new ArrayList<>();
public List<ArbitraryResourceData> resources = new ArrayList<>();
public ArbitraryResourceNameInfo() {
}

View File

@@ -2,29 +2,46 @@ package org.qortal.data.arbitrary;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import java.util.Map;
import static java.util.Arrays.stream;
import static java.util.stream.Collectors.toMap;
@XmlAccessorType(XmlAccessType.FIELD)
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..."),
READY("Ready", "Ready"),
MISSING_DATA("Missing data", "Unable to locate all files. Please try again later"),
BUILD_FAILED("Build failed", "Build failed. Please try again later"),
UNSUPPORTED("Unsupported", "Unsupported request"),
BLOCKED("Blocked", "Name is blocked so content cannot be served");
// Note: integer values must not be updated, as they are stored in the db
PUBLISHED(1, "Published", "Published but not yet downloaded"),
NOT_PUBLISHED(2, "Not published", "Resource does not exist"),
DOWNLOADING(3, "Downloading", "Locating and downloading files..."),
DOWNLOADED(4, "Downloaded", "Files downloaded"),
BUILDING(5, "Building", "Building..."),
READY(6, "Ready", "Ready"),
MISSING_DATA(7, "Missing data", "Unable to locate all files. Please try again later"),
BUILD_FAILED(8, "Build failed", "Build failed. Please try again later"),
UNSUPPORTED(9, "Unsupported", "Unsupported request"),
BLOCKED(10, "Blocked", "Name is blocked so content cannot be served");
public int value;
private String title;
private String description;
Status(String title, String description) {
private static final Map<Integer, Status> map = stream(Status.values())
.collect(toMap(status -> status.value, status -> status));
Status(int value, String title, String description) {
this.value = value;
this.title = title;
this.description = description;
}
public static Status valueOf(Integer value) {
if (value == null) {
return null;
}
return map.get(value);
}
}
private Status status;
@@ -46,7 +63,7 @@ public class ArbitraryResourceStatus {
this.description = status.description;
this.localChunkCount = localChunkCount;
this.totalChunkCount = totalChunkCount;
this.percentLoaded = (this.localChunkCount != null && this.totalChunkCount != null) ? this.localChunkCount / (float)this.totalChunkCount * 100.0f : null;
this.percentLoaded = (this.localChunkCount != null && this.totalChunkCount != null && this.totalChunkCount > 0) ? this.localChunkCount / (float)this.totalChunkCount * 100.0f : null;
}
public ArbitraryResourceStatus(Status status) {

View File

@@ -1,6 +1,7 @@
package org.qortal.data.network;
import java.util.Arrays;
import java.util.Objects;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
@@ -34,10 +35,6 @@ public class OnlineAccountData {
this.nonce = nonce;
}
public OnlineAccountData(long timestamp, byte[] signature, byte[] publicKey) {
this(timestamp, signature, publicKey, null);
}
public long getTimestamp() {
return this.timestamp;
}
@@ -76,6 +73,10 @@ public class OnlineAccountData {
if (otherOnlineAccountData.timestamp != this.timestamp)
return false;
// Almost as quick
if (!Objects.equals(otherOnlineAccountData.nonce, this.nonce))
return false;
if (!Arrays.equals(otherOnlineAccountData.publicKey, this.publicKey))
return false;
@@ -88,9 +89,10 @@ public class OnlineAccountData {
public int hashCode() {
int h = this.hash;
if (h == 0) {
this.hash = h = Long.hashCode(this.timestamp)
^ Arrays.hashCode(this.publicKey);
h = Objects.hash(timestamp, nonce);
h = 31 * h + Arrays.hashCode(publicKey);
// We don't use signature because newer aggregate signatures use random nonces
this.hash = h;
}
return h;
}

View File

@@ -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;
}

View File

@@ -12,6 +12,8 @@ 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.data.voting.VoteOnPollData;
import org.qortal.transaction.Transaction.ApprovalStatus;
import org.qortal.transaction.Transaction.TransactionType;
@@ -29,6 +31,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, VoteOnPollData.class,
IssueAssetTransactionData.class, TransferAssetTransactionData.class,
CreateAssetOrderTransactionData.class, CancelAssetOrderTransactionData.class,
MultiPaymentTransactionData.class, DeployAtTransactionData.class, MessageTransactionData.class, ATTransactionData.class,
@@ -76,6 +79,10 @@ public abstract class TransactionData {
@Schema(accessMode = AccessMode.READ_ONLY, hidden = true, description = "height of block containing transaction")
protected Integer blockHeight;
// Not always present
@Schema(accessMode = AccessMode.READ_ONLY, hidden = true, description = "sequence in block containing transaction")
protected Integer blockSequence;
// Not always present
@Schema(accessMode = AccessMode.READ_ONLY, description = "group-approval status")
protected ApprovalStatus approvalStatus;
@@ -106,6 +113,7 @@ public abstract class TransactionData {
this.fee = baseTransactionData.fee;
this.signature = baseTransactionData.signature;
this.blockHeight = baseTransactionData.blockHeight;
this.blockSequence = baseTransactionData.blockSequence;
this.approvalStatus = baseTransactionData.approvalStatus;
this.approvalHeight = baseTransactionData.approvalHeight;
}
@@ -174,6 +182,15 @@ public abstract class TransactionData {
this.blockHeight = blockHeight;
}
public Integer getBlockSequence() {
return this.blockSequence;
}
@XmlTransient
public void setBlockSequence(Integer blockSequence) {
this.blockSequence = blockSequence;
}
public ApprovalStatus getApprovalStatus() {
return approvalStatus;
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -9,6 +9,11 @@ public class VoteOnPollData {
// Constructors
// For JAXB
protected VoteOnPollData() {
super();
}
public VoteOnPollData(String pollName, byte[] voterPublicKey, int optionIndex) {
this.pollName = pollName;
this.voterPublicKey = voterPublicKey;
@@ -21,12 +26,24 @@ public class VoteOnPollData {
return this.pollName;
}
public void setPollName(String pollName) {
this.pollName = pollName;
}
public byte[] getVoterPublicKey() {
return this.voterPublicKey;
}
public void setVoterPublicKey(byte[] voterPublicKey) {
this.voterPublicKey = voterPublicKey;
}
public int getOptionIndex() {
return this.optionIndex;
}
public void setOptionIndex(int optionIndex) {
this.optionIndex = optionIndex;
}
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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.8.2";
private static final String MIN_PEER_VERSION = "4.1.1";
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

View File

@@ -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 | UnsupportedAddressTypeException 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;
}
@@ -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) {

View File

@@ -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());

View File

@@ -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);

View File

@@ -1,69 +0,0 @@
package org.qortal.network.message;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import org.qortal.data.network.OnlineAccountData;
import org.qortal.transform.Transformer;
import com.google.common.primitives.Ints;
import com.google.common.primitives.Longs;
public class GetOnlineAccountsMessage extends Message {
private static final int MAX_ACCOUNT_COUNT = 5000;
private List<OnlineAccountData> onlineAccounts;
public GetOnlineAccountsMessage(List<OnlineAccountData> onlineAccounts) {
super(MessageType.GET_ONLINE_ACCOUNTS);
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try {
bytes.write(Ints.toByteArray(onlineAccounts.size()));
for (OnlineAccountData onlineAccountData : onlineAccounts) {
bytes.write(Longs.toByteArray(onlineAccountData.getTimestamp()));
bytes.write(onlineAccountData.getPublicKey());
}
} catch (IOException e) {
throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
}
this.dataBytes = bytes.toByteArray();
this.checksumBytes = Message.generateChecksum(this.dataBytes);
}
private GetOnlineAccountsMessage(int id, List<OnlineAccountData> onlineAccounts) {
super(id, MessageType.GET_ONLINE_ACCOUNTS);
this.onlineAccounts = onlineAccounts.stream().limit(MAX_ACCOUNT_COUNT).collect(Collectors.toList());
}
public List<OnlineAccountData> getOnlineAccounts() {
return this.onlineAccounts;
}
public static Message fromByteBuffer(int id, ByteBuffer bytes) {
final int accountCount = bytes.getInt();
List<OnlineAccountData> onlineAccounts = new ArrayList<>(accountCount);
for (int i = 0; i < Math.min(MAX_ACCOUNT_COUNT, accountCount); ++i) {
long timestamp = bytes.getLong();
byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH];
bytes.get(publicKey);
onlineAccounts.add(new OnlineAccountData(timestamp, null, publicKey));
}
return new GetOnlineAccountsMessage(id, onlineAccounts);
}
}

View File

@@ -1,109 +0,0 @@
package org.qortal.network.message;
import com.google.common.primitives.Ints;
import com.google.common.primitives.Longs;
import org.qortal.data.network.OnlineAccountData;
import org.qortal.transform.Transformer;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* For requesting online accounts info from remote peer, given our list of online accounts.
*
* Different format to V1:
* V1 is: number of entries, then timestamp + pubkey for each entry
* V2 is: groups of: number of entries, timestamp, then pubkey for each entry
*
* Also V2 only builds online accounts message once!
*/
public class GetOnlineAccountsV2Message extends Message {
private List<OnlineAccountData> onlineAccounts;
public GetOnlineAccountsV2Message(List<OnlineAccountData> onlineAccounts) {
super(MessageType.GET_ONLINE_ACCOUNTS_V2);
// If we don't have ANY online accounts then it's an easier construction...
if (onlineAccounts.isEmpty()) {
// Always supply a number of accounts
this.dataBytes = Ints.toByteArray(0);
this.checksumBytes = Message.generateChecksum(this.dataBytes);
return;
}
// How many of each timestamp
Map<Long, Integer> countByTimestamp = new HashMap<>();
for (OnlineAccountData onlineAccountData : onlineAccounts) {
Long timestamp = onlineAccountData.getTimestamp();
countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v);
}
// We should know exactly how many bytes to allocate now
int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH)
+ onlineAccounts.size() * Transformer.PUBLIC_KEY_LENGTH;
ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize);
try {
for (long timestamp : countByTimestamp.keySet()) {
bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp)));
bytes.write(Longs.toByteArray(timestamp));
for (OnlineAccountData onlineAccountData : onlineAccounts) {
if (onlineAccountData.getTimestamp() == timestamp)
bytes.write(onlineAccountData.getPublicKey());
}
}
} catch (IOException e) {
throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
}
this.dataBytes = bytes.toByteArray();
this.checksumBytes = Message.generateChecksum(this.dataBytes);
}
private GetOnlineAccountsV2Message(int id, List<OnlineAccountData> onlineAccounts) {
super(id, MessageType.GET_ONLINE_ACCOUNTS_V2);
this.onlineAccounts = onlineAccounts;
}
public List<OnlineAccountData> getOnlineAccounts() {
return this.onlineAccounts;
}
public static Message fromByteBuffer(int id, ByteBuffer bytes) {
int accountCount = bytes.getInt();
List<OnlineAccountData> onlineAccounts = new ArrayList<>(accountCount);
while (accountCount > 0) {
long timestamp = bytes.getLong();
for (int i = 0; i < accountCount; ++i) {
byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH];
bytes.get(publicKey);
onlineAccounts.add(new OnlineAccountData(timestamp, null, publicKey));
}
if (bytes.hasRemaining()) {
accountCount = bytes.getInt();
} else {
// we've finished
accountCount = 0;
}
}
return new GetOnlineAccountsV2Message(id, onlineAccounts);
}
}

View File

@@ -43,11 +43,7 @@ public enum MessageType {
BLOCK_SUMMARIES(70, BlockSummariesMessage::fromByteBuffer),
GET_BLOCK_SUMMARIES(71, GetBlockSummariesMessage::fromByteBuffer),
BLOCK_SUMMARIES_V2(72, BlockSummariesV2Message::fromByteBuffer),
ONLINE_ACCOUNTS(80, OnlineAccountsMessage::fromByteBuffer),
GET_ONLINE_ACCOUNTS(81, GetOnlineAccountsMessage::fromByteBuffer),
ONLINE_ACCOUNTS_V2(82, OnlineAccountsV2Message::fromByteBuffer),
GET_ONLINE_ACCOUNTS_V2(83, GetOnlineAccountsV2Message::fromByteBuffer),
ONLINE_ACCOUNTS_V3(84, OnlineAccountsV3Message::fromByteBuffer),
GET_ONLINE_ACCOUNTS_V3(85, GetOnlineAccountsV3Message::fromByteBuffer),

View File

@@ -1,75 +0,0 @@
package org.qortal.network.message;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import org.qortal.data.network.OnlineAccountData;
import org.qortal.transform.Transformer;
import com.google.common.primitives.Ints;
import com.google.common.primitives.Longs;
public class OnlineAccountsMessage extends Message {
private static final int MAX_ACCOUNT_COUNT = 5000;
private List<OnlineAccountData> onlineAccounts;
public OnlineAccountsMessage(List<OnlineAccountData> onlineAccounts) {
super(MessageType.ONLINE_ACCOUNTS);
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try {
bytes.write(Ints.toByteArray(onlineAccounts.size()));
for (OnlineAccountData onlineAccountData : onlineAccounts) {
bytes.write(Longs.toByteArray(onlineAccountData.getTimestamp()));
bytes.write(onlineAccountData.getSignature());
bytes.write(onlineAccountData.getPublicKey());
}
} catch (IOException e) {
throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
}
this.dataBytes = bytes.toByteArray();
this.checksumBytes = Message.generateChecksum(this.dataBytes);
}
private OnlineAccountsMessage(int id, List<OnlineAccountData> onlineAccounts) {
super(id, MessageType.ONLINE_ACCOUNTS);
this.onlineAccounts = onlineAccounts.stream().limit(MAX_ACCOUNT_COUNT).collect(Collectors.toList());
}
public List<OnlineAccountData> getOnlineAccounts() {
return this.onlineAccounts;
}
public static Message fromByteBuffer(int id, ByteBuffer bytes) {
final int accountCount = bytes.getInt();
List<OnlineAccountData> onlineAccounts = new ArrayList<>(accountCount);
for (int i = 0; i < Math.min(MAX_ACCOUNT_COUNT, accountCount); ++i) {
long timestamp = bytes.getLong();
byte[] signature = new byte[Transformer.SIGNATURE_LENGTH];
bytes.get(signature);
byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH];
bytes.get(publicKey);
OnlineAccountData onlineAccountData = new OnlineAccountData(timestamp, signature, publicKey);
onlineAccounts.add(onlineAccountData);
}
return new OnlineAccountsMessage(id, onlineAccounts);
}
}

View File

@@ -1,113 +0,0 @@
package org.qortal.network.message;
import com.google.common.primitives.Ints;
import com.google.common.primitives.Longs;
import org.qortal.data.network.OnlineAccountData;
import org.qortal.transform.Transformer;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* For sending online accounts info to remote peer.
*
* Different format to V1:
* V1 is: number of entries, then timestamp + sig + pubkey for each entry
* V2 is: groups of: number of entries, timestamp, then sig + pubkey for each entry
*
* Also V2 only builds online accounts message once!
*/
public class OnlineAccountsV2Message extends Message {
private List<OnlineAccountData> onlineAccounts;
public OnlineAccountsV2Message(List<OnlineAccountData> onlineAccounts) {
super(MessageType.ONLINE_ACCOUNTS_V2);
// Shortcut in case we have no online accounts
if (onlineAccounts.isEmpty()) {
this.dataBytes = Ints.toByteArray(0);
this.checksumBytes = Message.generateChecksum(this.dataBytes);
return;
}
// How many of each timestamp
Map<Long, Integer> countByTimestamp = new HashMap<>();
for (OnlineAccountData onlineAccountData : onlineAccounts) {
Long timestamp = onlineAccountData.getTimestamp();
countByTimestamp.compute(timestamp, (k, v) -> v == null ? 1 : ++v);
}
// We should know exactly how many bytes to allocate now
int byteSize = countByTimestamp.size() * (Transformer.INT_LENGTH + Transformer.TIMESTAMP_LENGTH)
+ onlineAccounts.size() * (Transformer.SIGNATURE_LENGTH + Transformer.PUBLIC_KEY_LENGTH);
ByteArrayOutputStream bytes = new ByteArrayOutputStream(byteSize);
try {
for (long timestamp : countByTimestamp.keySet()) {
bytes.write(Ints.toByteArray(countByTimestamp.get(timestamp)));
bytes.write(Longs.toByteArray(timestamp));
for (OnlineAccountData onlineAccountData : onlineAccounts) {
if (onlineAccountData.getTimestamp() == timestamp) {
bytes.write(onlineAccountData.getSignature());
bytes.write(onlineAccountData.getPublicKey());
}
}
}
} catch (IOException e) {
throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream");
}
this.dataBytes = bytes.toByteArray();
this.checksumBytes = Message.generateChecksum(this.dataBytes);
}
private OnlineAccountsV2Message(int id, List<OnlineAccountData> onlineAccounts) {
super(id, MessageType.ONLINE_ACCOUNTS_V2);
this.onlineAccounts = onlineAccounts;
}
public List<OnlineAccountData> getOnlineAccounts() {
return this.onlineAccounts;
}
public static Message fromByteBuffer(int id, ByteBuffer bytes) throws MessageException {
int accountCount = bytes.getInt();
List<OnlineAccountData> onlineAccounts = new ArrayList<>(accountCount);
while (accountCount > 0) {
long timestamp = bytes.getLong();
for (int i = 0; i < accountCount; ++i) {
byte[] signature = new byte[Transformer.SIGNATURE_LENGTH];
bytes.get(signature);
byte[] publicKey = new byte[Transformer.PUBLIC_KEY_LENGTH];
bytes.get(publicKey);
onlineAccounts.add(new OnlineAccountData(timestamp, signature, publicKey));
}
if (bytes.hasRemaining()) {
accountCount = bytes.getInt();
} else {
// we've finished
accountCount = 0;
}
}
return new OnlineAccountsV2Message(id, onlineAccounts);
}
}

View File

@@ -99,9 +99,10 @@ public class OnlineAccountsV3Message extends Message {
bytes.get(publicKey);
// Nonce is optional - will be -1 if missing
// ... but we should skip/ignore an online account if it has no nonce
Integer nonce = bytes.getInt();
if (nonce < 0) {
nonce = null;
continue;
}
onlineAccounts.add(new OnlineAccountData(timestamp, signature, publicKey, nonce));

View File

@@ -1,9 +1,10 @@
package org.qortal.repository;
import org.qortal.api.SearchMode;
import org.qortal.arbitrary.misc.Service;
import org.qortal.data.arbitrary.ArbitraryResourceInfo;
import org.qortal.data.arbitrary.ArbitraryResourceNameInfo;
import org.qortal.data.network.ArbitraryPeerData;
import org.qortal.data.arbitrary.ArbitraryResourceData;
import org.qortal.data.arbitrary.ArbitraryResourceMetadata;
import org.qortal.data.arbitrary.ArbitraryResourceStatus;
import org.qortal.data.transaction.ArbitraryTransactionData;
import org.qortal.data.transaction.ArbitraryTransactionData.*;
@@ -11,23 +12,45 @@ import java.util.List;
public interface ArbitraryRepository {
// Utils
public boolean isDataLocal(byte[] signature) throws DataException;
public byte[] fetchData(byte[] signature) throws DataException;
// Transaction related
public void save(ArbitraryTransactionData arbitraryTransactionData) throws DataException;
public void delete(ArbitraryTransactionData arbitraryTransactionData) throws DataException;
public List<ArbitraryTransactionData> getArbitraryTransactions(String name, Service service, String identifier, long since) throws DataException;
public ArbitraryTransactionData getInitialTransaction(String name, Service service, Method method, String identifier) throws DataException;
public ArbitraryTransactionData getLatestTransaction(String name, Service service, Method method, String identifier) throws DataException;
public List<ArbitraryTransactionData> getArbitraryTransactions(boolean requireName, Integer limit, Integer offset, Boolean reverse) throws DataException;
public List<ArbitraryResourceInfo> getArbitraryResources(Service service, String identifier, List<String> names, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException;
public List<ArbitraryResourceInfo> searchArbitraryResources(Service service, String query, String identifier, String name, boolean prefixOnly, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException;
// Resource related
public List<ArbitraryResourceNameInfo> getArbitraryResourceCreatorNames(Service service, String identifier, boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException;
public ArbitraryResourceData getArbitraryResource(Service service, String name, String identifier) throws DataException;
public List<ArbitraryResourceData> getArbitraryResources(Integer limit, Integer offset, Boolean reverse) throws DataException;
public List<ArbitraryResourceData> getArbitraryResources(Service service, String identifier, List<String> names, boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked, Boolean includeMetadata, Boolean includeStatus, Integer limit, Integer offset, Boolean reverse) throws DataException;
public List<ArbitraryResourceData> searchArbitraryResources(Service service, String query, String identifier, List<String> names, String title, String description, boolean prefixOnly, List<String> namesFilter, boolean defaultResource, SearchMode mode, Integer minLevel, Boolean followedOnly, Boolean excludeBlocked, Boolean includeMetadata, Boolean includeStatus, Long before, Long after, Integer limit, Integer offset, Boolean reverse) throws DataException;
// Arbitrary resources cache save/load
public void save(ArbitraryResourceData arbitraryResourceData) throws DataException;
public void setStatus(ArbitraryResourceData arbitraryResourceData, ArbitraryResourceStatus.Status status) throws DataException;
public void delete(ArbitraryResourceData arbitraryResourceData) throws DataException;
public void save(ArbitraryResourceMetadata metadata) throws DataException;
public void delete(ArbitraryResourceMetadata metadata) throws DataException;
}

View File

@@ -14,10 +14,12 @@ public interface NameRepository {
public boolean reducedNameExists(String reducedName) throws DataException;
public List<NameData> getAllNames(Integer limit, Integer offset, Boolean reverse) throws DataException;
public List<NameData> searchNames(String query, boolean prefixOnly, Integer limit, Integer offset, Boolean reverse) throws DataException;
public List<NameData> getAllNames(Long after, Integer limit, Integer offset, Boolean reverse) throws DataException;
public default List<NameData> getAllNames() throws DataException {
return getAllNames(null, null, null);
return getAllNames(null, null, null, null);
}
public List<NameData> getNamesForSale(Integer limit, Integer offset, Boolean reverse) throws DataException;

View File

@@ -2,9 +2,23 @@ package org.qortal.repository;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qortal.block.Block;
import org.qortal.crypto.Crypto;
import org.qortal.data.at.ATData;
import org.qortal.data.block.BlockData;
import org.qortal.data.transaction.ATTransactionData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.gui.SplashFrame;
import org.qortal.settings.Settings;
import org.qortal.transaction.Transaction;
import org.qortal.transform.block.BlockTransformation;
import java.sql.SQLException;
import java.util.*;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;
import static org.qortal.transaction.Transaction.TransactionType.AT;
public abstract class RepositoryManager {
private static final Logger LOGGER = LogManager.getLogger(RepositoryManager.class);
@@ -56,6 +70,164 @@ public abstract class RepositoryManager {
}
}
public static boolean needsTransactionSequenceRebuild(Repository repository) throws DataException {
// Check if we have any transactions without a block_sequence
List<byte[]> testSignatures = repository.getTransactionRepository().getSignaturesMatchingCustomCriteria(
null, Arrays.asList("block_height IS NOT NULL AND block_sequence IS NULL"), new ArrayList<>(), 100);
if (testSignatures.isEmpty()) {
// block_sequence intact, so assume complete
return false;
}
return true;
}
public static boolean rebuildTransactionSequences(Repository repository) throws DataException {
if (Settings.getInstance().isLite()) {
// Lite nodes have no blockchain
return false;
}
if (Settings.getInstance().isTopOnly()) {
// topOnly nodes are unable to perform this reindex, and so are temporarily unsupported
throw new DataException("topOnly nodes are now unsupported, as they are missing data required for a db reshape");
}
try {
// Check if we have any unpopulated block_sequence values for the first 1000 blocks
if (!needsTransactionSequenceRebuild(repository)) {
// block_sequence already populated for the first 1000 blocks, so assume complete.
// We avoid checkpointing and prevent the node from starting up in the case of a rebuild failure, so
// we shouldn't ever be left in a partially rebuilt state.
return false;
}
LOGGER.info("Rebuilding transaction sequences - this will take a while...");
SplashFrame.getInstance().updateStatus("Rebuilding transactions - please wait...");
int blockchainHeight = repository.getBlockRepository().getBlockchainHeight();
int totalTransactionCount = 0;
for (int height = 1; height <= blockchainHeight; ++height) {
List<TransactionData> inputTransactions = new ArrayList<>();
// Fetch block and transactions
BlockData blockData = repository.getBlockRepository().fromHeight(height);
boolean loadedFromArchive = false;
if (blockData == null) {
// Get (non-AT) transactions from the archive
BlockTransformation blockTransformation = BlockArchiveReader.getInstance().fetchBlockAtHeight(height);
blockData = blockTransformation.getBlockData();
inputTransactions = blockTransformation.getTransactions(); // This doesn't include AT transactions
loadedFromArchive = true;
}
else {
// Get transactions from db
Block block = new Block(repository, blockData);
for (Transaction transaction : block.getTransactions()) {
inputTransactions.add(transaction.getTransactionData());
}
}
if (blockData == null) {
throw new DataException("Missing block data");
}
List<TransactionData> transactions = new ArrayList<>();
if (loadedFromArchive) {
List<TransactionData> transactionDataList = new ArrayList<>(blockData.getTransactionCount());
// Fetch any AT transactions in this block
List<byte[]> atSignatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, height, height);
for (byte[] s : atSignatures) {
TransactionData transactionData = repository.getTransactionRepository().fromSignature(s);
if (transactionData.getType() == AT) {
transactionDataList.add(transactionData);
}
}
List<ATTransactionData> atTransactions = new ArrayList<>();
for (TransactionData transactionData : transactionDataList) {
ATTransactionData atTransactionData = (ATTransactionData) transactionData;
atTransactions.add(atTransactionData);
}
// Create sorted list of ATs by creation time
List<ATData> ats = new ArrayList<>();
for (ATTransactionData atTransactionData : atTransactions) {
ATData atData = repository.getATRepository().fromATAddress(atTransactionData.getATAddress());
boolean hasExistingEntry = ats.stream().anyMatch(a -> Objects.equals(a.getATAddress(), atTransactionData.getATAddress()));
if (!hasExistingEntry) {
ats.add(atData);
}
}
// Sort list of ATs by creation date
ats.sort(Comparator.comparingLong(ATData::getCreation));
// Loop through unique ATs
for (ATData atData : ats) {
List<ATTransactionData> thisAtTransactions = atTransactions.stream()
.filter(t -> Objects.equals(t.getATAddress(), atData.getATAddress()))
.collect(Collectors.toList());
int count = thisAtTransactions.size();
if (count == 1) {
ATTransactionData atTransactionData = thisAtTransactions.get(0);
transactions.add(atTransactionData);
}
else if (count == 2) {
String atCreatorAddress = Crypto.toAddress(atData.getCreatorPublicKey());
ATTransactionData atTransactionData1 = thisAtTransactions.stream()
.filter(t -> !Objects.equals(t.getRecipient(), atCreatorAddress))
.findFirst().orElse(null);
transactions.add(atTransactionData1);
ATTransactionData atTransactionData2 = thisAtTransactions.stream()
.filter(t -> Objects.equals(t.getRecipient(), atCreatorAddress))
.findFirst().orElse(null);
transactions.add(atTransactionData2);
}
else if (count > 2) {
LOGGER.info("Error: AT has more than 2 output transactions");
}
}
}
// Add all the regular transactions now that AT transactions have been handled
transactions.addAll(inputTransactions);
totalTransactionCount += transactions.size();
// Loop through and update sequences
for (int sequence = 0; sequence < transactions.size(); ++sequence) {
TransactionData transactionData = transactions.get(sequence);
// Update transaction's sequence in repository
repository.getTransactionRepository().updateBlockSequence(transactionData.getSignature(), sequence);
}
if (height % 10000 == 0) {
LOGGER.info("Rebuilt sequences for {} blocks (total transactions: {})", height, totalTransactionCount);
}
repository.saveChanges();
}
LOGGER.info("Completed rebuild of transaction sequences.");
return true;
}
catch (DataException e) {
LOGGER.info("Unable to rebuild transaction sequences: {}. The database may have been left in an inconsistent state.", e.getMessage());
// Throw an exception so that the node startup is halted, allowing for a retry next time.
repository.discardChanges();
throw new DataException("Rebuild of transaction sequences failed.");
}
}
public static void setRequestedCheckpoint(Boolean quick) {
quickCheckpointRequested = quick;
}

View File

@@ -125,6 +125,23 @@ public interface TransactionRepository {
public List<byte[]> getSignaturesMatchingCustomCriteria(TransactionType txType, List<String> whereClauses,
List<Object> bindParams) throws DataException;
/**
* Returns signatures for transactions that match search criteria, with optional limit.
* <p>
* Alternate version that allows for custom where clauses and bind params.
* Only use for very specific use cases, such as the names integrity check.
* Not advised to be used otherwise, given that it could be possible for
* unsanitized inputs to be passed in if not careful.
*
* @param txType
* @param whereClauses
* @param bindParams
* @return
* @throws DataException
*/
public List<byte[]> getSignaturesMatchingCustomCriteria(TransactionType txType, List<String> whereClauses,
List<Object> bindParams, Integer limit) throws DataException;
/**
* Returns signature for latest auto-update transaction.
* <p>
@@ -297,7 +314,7 @@ public interface TransactionRepository {
* @return list of transactions, or empty if none.
* @throws DataException
*/
public List<TransactionData> getUnconfirmedTransactions(EnumSet<TransactionType> excludedTxTypes) throws DataException;
public List<TransactionData> getUnconfirmedTransactions(EnumSet<TransactionType> excludedTxTypes, Integer limit) throws DataException;
/**
* Remove transaction from unconfirmed transactions pile.
@@ -309,6 +326,8 @@ public interface TransactionRepository {
public void updateBlockHeight(byte[] signature, Integer height) throws DataException;
public void updateBlockSequence(byte[] signature, Integer sequence) throws DataException;
public void updateApprovalHeight(byte[] signature, Integer approvalHeight) throws DataException;
/**

View File

@@ -9,6 +9,8 @@ public interface VotingRepository {
// Polls
public List<PollData> getAllPolls(Integer limit, Integer offset, Boolean reverse) throws DataException;
public PollData fromPollName(String pollName) throws DataException;
public boolean pollExists(String pollName) throws DataException;

View File

@@ -296,10 +296,9 @@ public class HSQLDBATRepository implements ATRepository {
@Override
public Integer getATCreationBlockHeight(String atAddress) throws DataException {
String sql = "SELECT height "
String sql = "SELECT block_height "
+ "FROM DeployATTransactions "
+ "JOIN BlockTransactions ON transaction_signature = signature "
+ "JOIN Blocks ON Blocks.signature = block_signature "
+ "JOIN Transactions USING (signature) "
+ "WHERE AT_address = ? "
+ "LIMIT 1";
@@ -877,18 +876,17 @@ public class HSQLDBATRepository implements ATRepository {
public NextTransactionInfo findNextTransaction(String recipient, int height, int sequence) throws DataException {
// We only need to search for a subset of transaction types: MESSAGE, PAYMENT or AT
String sql = "SELECT height, sequence, Transactions.signature "
String sql = "SELECT block_height, block_sequence, Transactions.signature "
+ "FROM ("
+ "SELECT signature FROM PaymentTransactions WHERE recipient = ? "
+ "UNION "
+ "SELECT signature FROM MessageTransactions WHERE recipient = ? "
+ "UNION "
+ "SELECT signature FROM ATTransactions WHERE recipient = ?"
+ ") AS Transactions "
+ "JOIN BlockTransactions ON BlockTransactions.transaction_signature = Transactions.signature "
+ "JOIN Blocks ON Blocks.signature = BlockTransactions.block_signature "
+ "WHERE (height > ? OR (height = ? AND sequence > ?)) "
+ "ORDER BY height ASC, sequence ASC "
+ ") AS SelectedTransactions "
+ "JOIN Transactions USING (signature)"
+ "WHERE (block_height > ? OR (block_height = ? AND block_sequence > ?)) "
+ "ORDER BY block_height ASC, block_sequence ASC "
+ "LIMIT 1";
Object[] bindParams = new Object[] { recipient, recipient, recipient, height, height, sequence };

View File

@@ -2,10 +2,13 @@ package org.qortal.repository.hsqldb;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.bouncycastle.util.Longs;
import org.qortal.api.SearchMode;
import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata;
import org.qortal.arbitrary.misc.Category;
import org.qortal.arbitrary.misc.Service;
import org.qortal.data.arbitrary.ArbitraryResourceInfo;
import org.qortal.data.arbitrary.ArbitraryResourceNameInfo;
import org.qortal.data.arbitrary.ArbitraryResourceData;
import org.qortal.data.arbitrary.ArbitraryResourceMetadata;
import org.qortal.data.arbitrary.ArbitraryResourceStatus;
import org.qortal.data.transaction.ArbitraryTransactionData;
import org.qortal.data.transaction.ArbitraryTransactionData.*;
import org.qortal.data.transaction.BaseTransactionData;
@@ -16,11 +19,13 @@ import org.qortal.arbitrary.ArbitraryDataFile;
import org.qortal.transaction.ArbitraryTransaction;
import org.qortal.transaction.Transaction.ApprovalStatus;
import org.qortal.utils.Base58;
import org.qortal.utils.ListUtils;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public class HSQLDBArbitraryRepository implements ArbitraryRepository {
@@ -40,6 +45,9 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository {
return (ArbitraryTransactionData) transactionData;
}
// Utils
@Override
public boolean isDataLocal(byte[] signature) throws DataException {
ArbitraryTransactionData transactionData = getTransactionData(signature);
@@ -112,6 +120,9 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository {
return null;
}
// Transaction related
@Override
public void save(ArbitraryTransactionData arbitraryTransactionData) throws DataException {
// Already hashed? Nothing to do
@@ -210,8 +221,12 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository {
}
}
@Override
public ArbitraryTransactionData getLatestTransaction(String name, Service service, Method method, String identifier) throws DataException {
private ArbitraryTransactionData getSingleTransaction(String name, Service service, Method method, String identifier, boolean firstNotLast) throws DataException {
if (name == null || service == null) {
// Required fields
return null;
}
StringBuilder sql = new StringBuilder(1024);
sql.append("SELECT type, reference, signature, creator, created_when, fee, " +
@@ -227,7 +242,16 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository {
sql.append(method.value);
}
sql.append("ORDER BY created_when DESC LIMIT 1");
sql.append(" ORDER BY created_when");
if (firstNotLast) {
sql.append(" ASC");
}
else {
sql.append(" DESC");
}
sql.append(" LIMIT 1");
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), name.toLowerCase(), service.value, identifier, identifier)) {
if (resultSet == null)
@@ -283,21 +307,286 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository {
}
@Override
public List<ArbitraryResourceInfo> getArbitraryResources(Service service, String identifier, List<String> names,
boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException {
public ArbitraryTransactionData getInitialTransaction(String name, Service service, Method method, String identifier) throws DataException {
return this.getSingleTransaction(name, service, method, identifier, true);
}
@Override
public ArbitraryTransactionData getLatestTransaction(String name, Service service, Method method, String identifier) throws DataException {
return this.getSingleTransaction(name, service, method, identifier, false);
}
public List<ArbitraryTransactionData> getArbitraryTransactions(boolean requireName, Integer limit, Integer offset, Boolean reverse) throws DataException {
StringBuilder sql = new StringBuilder(512);
sql.append("SELECT type, reference, signature, creator, created_when, fee, " +
"tx_group_id, block_height, approval_status, approval_height, " +
"version, nonce, service, size, is_data_raw, data, metadata_hash, " +
"name, identifier, update_method, secret, compression FROM ArbitraryTransactions " +
"JOIN Transactions USING (signature)");
if (requireName) {
sql.append(" WHERE name IS NOT NULL");
}
sql.append(" ORDER BY created_when");
if (reverse != null && reverse) {
sql.append(" DESC");
}
HSQLDBRepository.limitOffsetSql(sql, limit, offset);
List<ArbitraryTransactionData> arbitraryTransactionData = new ArrayList<>();
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString())) {
if (resultSet == null)
return null;
do {
//TransactionType type = TransactionType.valueOf(resultSet.getInt(1));
byte[] reference = resultSet.getBytes(2);
byte[] signature = resultSet.getBytes(3);
byte[] creatorPublicKey = resultSet.getBytes(4);
long timestamp = resultSet.getLong(5);
Long fee = resultSet.getLong(6);
if (fee == 0 && resultSet.wasNull())
fee = null;
int txGroupId = resultSet.getInt(7);
Integer blockHeight = resultSet.getInt(8);
if (blockHeight == 0 && resultSet.wasNull())
blockHeight = null;
ApprovalStatus approvalStatus = ApprovalStatus.valueOf(resultSet.getInt(9));
Integer approvalHeight = resultSet.getInt(10);
if (approvalHeight == 0 && resultSet.wasNull())
approvalHeight = null;
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, creatorPublicKey, fee, approvalStatus, blockHeight, approvalHeight, signature);
int version = resultSet.getInt(11);
int nonce = resultSet.getInt(12);
int serviceInt = resultSet.getInt(13);
int size = resultSet.getInt(14);
boolean isDataRaw = resultSet.getBoolean(15); // NOT NULL, so no null to false
DataType dataType = isDataRaw ? DataType.RAW_DATA : DataType.DATA_HASH;
byte[] data = resultSet.getBytes(16);
byte[] metadataHash = resultSet.getBytes(17);
String nameResult = resultSet.getString(18);
String identifierResult = resultSet.getString(19);
Method method = Method.valueOf(resultSet.getInt(20));
byte[] secret = resultSet.getBytes(21);
Compression compression = Compression.valueOf(resultSet.getInt(22));
// FUTURE: get payments from signature if needed. Avoiding for now to reduce database calls.
ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData,
version, serviceInt, nonce, size, nameResult, identifierResult, method, secret,
compression, data, dataType, metadataHash, null);
arbitraryTransactionData.add(transactionData);
} while (resultSet.next());
return arbitraryTransactionData;
} catch (SQLException e) {
throw new DataException("Unable to fetch arbitrary transactions from repository", e);
}
}
// Resource related
@Override
public ArbitraryResourceData getArbitraryResource(Service service, String name, String identifier) throws DataException {
StringBuilder sql = new StringBuilder(512);
List<Object> bindParams = new ArrayList<>();
sql.append("SELECT name, service, identifier, MAX(size) AS max_size FROM ArbitraryTransactions WHERE 1=1");
// Name is required
if (name == null) {
return null;
}
sql.append("SELECT name, service, identifier, size, status, created_when, updated_when, " +
"title, description, category, tag1, tag2, tag3, tag4, tag5 " +
"FROM ArbitraryResourcesCache " +
"LEFT JOIN ArbitraryMetadataCache USING (service, name, identifier) " +
"WHERE ArbitraryResourcesCache.service = ? AND ArbitraryResourcesCache.name = ?");
bindParams.add(service.value);
bindParams.add(name);
if (identifier != null) {
sql.append(" AND identifier = ?");
bindParams.add(identifier);
}
else {
sql.append(" AND identifier IS NULL");
}
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
if (resultSet == null)
return null;
String nameResult = resultSet.getString(1);
Service serviceResult = Service.valueOf(resultSet.getInt(2));
String identifierResult = resultSet.getString(3);
Integer sizeResult = resultSet.getInt(4);
Integer status = resultSet.getInt(5);
Long created = resultSet.getLong(6);
Long updated = resultSet.getLong(7);
// Optional metadata fields
String title = resultSet.getString(8);
String description = resultSet.getString(9);
String category = resultSet.getString(10);
String tag1 = resultSet.getString(11);
String tag2 = resultSet.getString(12);
String tag3 = resultSet.getString(13);
String tag4 = resultSet.getString(14);
String tag5 = resultSet.getString(15);
if (Objects.equals(identifierResult, "default")) {
// Map "default" back to null. This is optional but probably less confusing than returning "default".
identifierResult = null;
}
ArbitraryResourceData arbitraryResourceData = new ArbitraryResourceData();
arbitraryResourceData.name = nameResult;
arbitraryResourceData.service = serviceResult;
arbitraryResourceData.identifier = identifierResult;
arbitraryResourceData.size = sizeResult;
arbitraryResourceData.setStatus(ArbitraryResourceStatus.Status.valueOf(status));
arbitraryResourceData.created = created;
arbitraryResourceData.updated = (updated == 0) ? null : updated;
ArbitraryResourceMetadata metadata = new ArbitraryResourceMetadata();
metadata.setTitle(title);
metadata.setDescription(description);
metadata.setCategory(Category.uncategorizedValueOf(category));
List<String> tags = new ArrayList<>();
if (tag1 != null) tags.add(tag1);
if (tag2 != null) tags.add(tag2);
if (tag3 != null) tags.add(tag3);
if (tag4 != null) tags.add(tag4);
if (tag5 != null) tags.add(tag5);
metadata.setTags(!tags.isEmpty() ? tags : null);
if (metadata.hasMetadata()) {
arbitraryResourceData.metadata = metadata;
}
return arbitraryResourceData;
} catch (SQLException e) {
throw new DataException("Unable to fetch arbitrary resource from repository", e);
}
}
@Override
public List<ArbitraryResourceData> getArbitraryResources(Integer limit, Integer offset, Boolean reverse) throws DataException {
StringBuilder sql = new StringBuilder(512);
List<Object> bindParams = new ArrayList<>();
sql.append("SELECT name, service, identifier, size, status, created_when, updated_when, " +
"title, description, category, tag1, tag2, tag3, tag4, tag5 " +
"FROM ArbitraryResourcesCache " +
"LEFT JOIN ArbitraryMetadataCache USING (service, name, identifier) " +
"WHERE name IS NOT NULL ORDER BY created_when");
if (reverse != null && reverse) {
sql.append(" DESC");
}
HSQLDBRepository.limitOffsetSql(sql, limit, offset);
List<ArbitraryResourceData> arbitraryResources = new ArrayList<>();
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
if (resultSet == null)
return arbitraryResources;
do {
String nameResult = resultSet.getString(1);
Service serviceResult = Service.valueOf(resultSet.getInt(2));
String identifierResult = resultSet.getString(3);
Integer sizeResult = resultSet.getInt(4);
Integer status = resultSet.getInt(5);
Long created = resultSet.getLong(6);
Long updated = resultSet.getLong(7);
// Optional metadata fields
String title = resultSet.getString(8);
String description = resultSet.getString(9);
String category = resultSet.getString(10);
String tag1 = resultSet.getString(11);
String tag2 = resultSet.getString(12);
String tag3 = resultSet.getString(13);
String tag4 = resultSet.getString(14);
String tag5 = resultSet.getString(15);
if (Objects.equals(identifierResult, "default")) {
// Map "default" back to null. This is optional but probably less confusing than returning "default".
identifierResult = null;
}
ArbitraryResourceData arbitraryResourceData = new ArbitraryResourceData();
arbitraryResourceData.name = nameResult;
arbitraryResourceData.service = serviceResult;
arbitraryResourceData.identifier = identifierResult;
arbitraryResourceData.size = sizeResult;
arbitraryResourceData.setStatus(ArbitraryResourceStatus.Status.valueOf(status));
arbitraryResourceData.created = created;
arbitraryResourceData.updated = (updated == 0) ? null : updated;
ArbitraryResourceMetadata metadata = new ArbitraryResourceMetadata();
metadata.setTitle(title);
metadata.setDescription(description);
metadata.setCategory(Category.uncategorizedValueOf(category));
List<String> tags = new ArrayList<>();
if (tag1 != null) tags.add(tag1);
if (tag2 != null) tags.add(tag2);
if (tag3 != null) tags.add(tag3);
if (tag4 != null) tags.add(tag4);
if (tag5 != null) tags.add(tag5);
metadata.setTags(!tags.isEmpty() ? tags : null);
if (metadata.hasMetadata()) {
arbitraryResourceData.metadata = metadata;
}
arbitraryResources.add(arbitraryResourceData);
} while (resultSet.next());
return arbitraryResources;
} catch (SQLException e) {
throw new DataException("Unable to fetch arbitrary resources from repository", e);
}
}
@Override
public List<ArbitraryResourceData> getArbitraryResources(Service service, String identifier, List<String> names,
boolean defaultResource, Boolean followedOnly, Boolean excludeBlocked,
Boolean includeMetadata, Boolean includeStatus,
Integer limit, Integer offset, Boolean reverse) throws DataException {
StringBuilder sql = new StringBuilder(512);
List<Object> bindParams = new ArrayList<>();
sql.append("SELECT name, service, identifier, size, status, created_when, updated_when, " +
"title, description, category, tag1, tag2, tag3, tag4, tag5 " +
"FROM ArbitraryResourcesCache " +
"LEFT JOIN ArbitraryMetadataCache USING (service, name, identifier) " +
"WHERE name IS NOT NULL");
if (service != null) {
sql.append(" AND service = ");
sql.append(service.value);
sql.append(" AND service = ?");
bindParams.add(service.value);
}
if (defaultResource) {
// Default resource requested - use NULL identifier
sql.append(" AND identifier IS NULL");
sql.append(" AND identifier='default'");
}
else {
// Non-default resource requested
@@ -319,7 +608,37 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository {
sql.append(")");
}
sql.append(" GROUP BY name, service, identifier ORDER BY name COLLATE SQL_TEXT_UCC_NO_PAD");
// Handle "followed only"
if (followedOnly != null && followedOnly) {
List<String> followedNames = ListUtils.followedNames();
if (followedNames != null && !followedNames.isEmpty()) {
sql.append(" AND name IN (?");
bindParams.add(followedNames.get(0));
for (int i = 1; i < followedNames.size(); ++i) {
sql.append(", ?");
bindParams.add(followedNames.get(i));
}
sql.append(")");
}
}
// Handle "exclude blocked"
if (excludeBlocked != null && excludeBlocked) {
List<String> blockedNames = ListUtils.blockedNames();
if (blockedNames != null && !blockedNames.isEmpty()) {
sql.append(" AND name NOT IN (?");
bindParams.add(blockedNames.get(0));
for (int i = 1; i < blockedNames.size(); ++i) {
sql.append(", ?");
bindParams.add(blockedNames.get(i));
}
sql.append(")");
}
}
sql.append(" ORDER BY name COLLATE SQL_TEXT_UCC_NO_PAD");
if (reverse != null && reverse) {
sql.append(" DESC");
@@ -327,52 +646,122 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository {
HSQLDBRepository.limitOffsetSql(sql, limit, offset);
List<ArbitraryResourceInfo> arbitraryResources = new ArrayList<>();
List<ArbitraryResourceData> arbitraryResources = new ArrayList<>();
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
if (resultSet == null)
return null;
return arbitraryResources;
do {
String nameResult = resultSet.getString(1);
Service serviceResult = Service.valueOf(resultSet.getInt(2));
String identifierResult = resultSet.getString(3);
Integer sizeResult = resultSet.getInt(4);
Integer status = resultSet.getInt(5);
Long created = resultSet.getLong(6);
Long updated = resultSet.getLong(7);
// We should filter out resources without names
if (nameResult == null) {
continue;
// Optional metadata fields
String title = resultSet.getString(8);
String description = resultSet.getString(9);
String category = resultSet.getString(10);
String tag1 = resultSet.getString(11);
String tag2 = resultSet.getString(12);
String tag3 = resultSet.getString(13);
String tag4 = resultSet.getString(14);
String tag5 = resultSet.getString(15);
if (Objects.equals(identifierResult, "default")) {
// Map "default" back to null. This is optional but probably less confusing than returning "default".
identifierResult = null;
}
ArbitraryResourceInfo arbitraryResourceInfo = new ArbitraryResourceInfo();
arbitraryResourceInfo.name = nameResult;
arbitraryResourceInfo.service = serviceResult;
arbitraryResourceInfo.identifier = identifierResult;
arbitraryResourceInfo.size = Longs.valueOf(sizeResult);
ArbitraryResourceData arbitraryResourceData = new ArbitraryResourceData();
arbitraryResourceData.name = nameResult;
arbitraryResourceData.service = serviceResult;
arbitraryResourceData.identifier = identifierResult;
arbitraryResourceData.size = sizeResult;
arbitraryResourceData.created = created;
arbitraryResourceData.updated = (updated == 0) ? null : updated;
arbitraryResources.add(arbitraryResourceInfo);
if (includeStatus != null && includeStatus) {
arbitraryResourceData.setStatus(ArbitraryResourceStatus.Status.valueOf(status));
}
if (includeMetadata != null && includeMetadata) {
// TODO: we could avoid the join altogether
ArbitraryResourceMetadata metadata = new ArbitraryResourceMetadata();
metadata.setTitle(title);
metadata.setDescription(description);
metadata.setCategory(Category.uncategorizedValueOf(category));
List<String> tags = new ArrayList<>();
if (tag1 != null) tags.add(tag1);
if (tag2 != null) tags.add(tag2);
if (tag3 != null) tags.add(tag3);
if (tag4 != null) tags.add(tag4);
if (tag5 != null) tags.add(tag5);
metadata.setTags(!tags.isEmpty() ? tags : null);
if (metadata.hasMetadata()) {
arbitraryResourceData.metadata = metadata;
}
}
arbitraryResources.add(arbitraryResourceData);
} while (resultSet.next());
return arbitraryResources;
} catch (SQLException e) {
throw new DataException("Unable to fetch arbitrary transactions from repository", e);
throw new DataException("Unable to fetch arbitrary resources from repository", e);
}
}
@Override
public List<ArbitraryResourceInfo> searchArbitraryResources(Service service, String query, String identifier, String name, boolean prefixOnly,
boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException {
public List<ArbitraryResourceData> searchArbitraryResources(Service service, String query, String identifier, List<String> names, String title, String description, boolean prefixOnly,
List<String> exactMatchNames, boolean defaultResource, SearchMode mode, Integer minLevel, Boolean followedOnly, Boolean excludeBlocked,
Boolean includeMetadata, Boolean includeStatus, Long before, Long after, Integer limit, Integer offset, Boolean reverse) throws DataException {
StringBuilder sql = new StringBuilder(512);
List<Object> bindParams = new ArrayList<>();
sql.append("SELECT name, service, identifier, MAX(size) AS max_size, MIN(created_when) AS date_created, MAX(created_when) AS date_updated " +
"FROM ArbitraryTransactions " +
"JOIN Transactions USING (signature) " +
"WHERE 1=1");
sql.append("SELECT name, service, identifier, size, status, created_when, updated_when, " +
"title, description, category, tag1, tag2, tag3, tag4, tag5 " +
"FROM ArbitraryResourcesCache");
// Default to "latest" mode
if (mode == null) {
mode = SearchMode.LATEST;
}
switch (mode) {
case LATEST:
// Include latest item only for a name/service combination
sql.append(" JOIN (SELECT name, service, MAX(created_when) AS latest " +
"FROM ArbitraryResourcesCache GROUP BY name, service) LatestResources " +
"ON name=LatestResources.name AND service=LatestResources.service " +
"AND created_when=LatestResources.latest");
break;
case ALL:
break;
}
if (minLevel != null) {
// Join tables necessary for level filter
sql.append(" JOIN Names USING (name) JOIN Accounts ON Accounts.account=Names.owner");
}
sql.append(" LEFT JOIN ArbitraryMetadataCache USING (service, name, identifier) WHERE name IS NOT NULL");
if (minLevel != null) {
// Add level filter
sql.append(" AND Accounts.level >= ?");
bindParams.add(minLevel);
}
if (service != null) {
sql.append(" AND service = ");
sql.append(service.value);
sql.append(" AND service = ?");
bindParams.add(service.value);
}
// Handle general query matches
@@ -384,14 +773,13 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository {
if (defaultResource) {
// Default resource requested - use NULL identifier and search name only
sql.append(" AND LCASE(name) LIKE ? AND identifier IS NULL");
sql.append(" AND LCASE(name) LIKE ? AND identifier='default'");
bindParams.add(queryWildcard);
} else {
// Non-default resource requested
// In this case we search the identifier as well as the name
sql.append(" AND (LCASE(name) LIKE ? OR LCASE(identifier) LIKE ?)");
bindParams.add(queryWildcard);
bindParams.add(queryWildcard);
sql.append(" AND (LCASE(name) LIKE ? OR LCASE(identifier) LIKE ? OR LCASE(title) LIKE ? OR LCASE(description) LIKE ?)");
bindParams.add(queryWildcard); bindParams.add(queryWildcard); bindParams.add(queryWildcard); bindParams.add(queryWildcard);
}
}
@@ -403,15 +791,89 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository {
bindParams.add(queryWildcard);
}
// Handle name matches
if (name != null) {
// Search anywhere in the identifier, unless "prefixOnly" has been requested
String queryWildcard = prefixOnly ? String.format("%s%%", name.toLowerCase()) : String.format("%%%s%%", name.toLowerCase());
sql.append(" AND LCASE(name) LIKE ?");
// Handle title metadata matches
if (title != null) {
// Search anywhere in the title, unless "prefixOnly" has been requested
String queryWildcard = prefixOnly ? String.format("%s%%", title.toLowerCase()) : String.format("%%%s%%", title.toLowerCase());
sql.append(" AND LCASE(title) LIKE ?");
bindParams.add(queryWildcard);
}
sql.append(" GROUP BY name, service, identifier ORDER BY date_created");
// Handle description metadata matches
if (description != null) {
// Search anywhere in the description, unless "prefixOnly" has been requested
String queryWildcard = prefixOnly ? String.format("%s%%", description.toLowerCase()) : String.format("%%%s%%", description.toLowerCase());
sql.append(" AND LCASE(description) LIKE ?");
bindParams.add(queryWildcard);
}
// Handle name searches
if (names != null && !names.isEmpty()) {
sql.append(" AND (");
for (int i = 0; i < names.size(); ++i) {
// Search anywhere in the name, unless "prefixOnly" has been requested
String queryWildcard = prefixOnly ? String.format("%s%%", names.get(i).toLowerCase()) : String.format("%%%s%%", names.get(i).toLowerCase());
if (i > 0) sql.append(" OR ");
sql.append("LCASE(name) LIKE ?");
bindParams.add(queryWildcard);
}
sql.append(")");
}
// Handle name exact matches
if (exactMatchNames != null && !exactMatchNames.isEmpty()) {
sql.append(" AND LCASE(name) IN (?");
bindParams.add(exactMatchNames.get(0).toLowerCase());
for (int i = 1; i < exactMatchNames.size(); ++i) {
sql.append(", ?");
bindParams.add(exactMatchNames.get(i).toLowerCase());
}
sql.append(")");
}
// Timestamp range
if (before != null) {
sql.append(" AND created_when < ?");
bindParams.add(before);
}
if (after != null) {
sql.append(" AND created_when > ?");
bindParams.add(after);
}
// Handle "followed only"
if (followedOnly != null && followedOnly) {
List<String> followedNames = ListUtils.followedNames();
if (followedNames != null && !followedNames.isEmpty()) {
sql.append(" AND LCASE(name) IN (?");
bindParams.add(followedNames.get(0).toLowerCase());
for (int i = 1; i < followedNames.size(); ++i) {
sql.append(", ?");
bindParams.add(followedNames.get(i).toLowerCase());
}
sql.append(")");
}
}
// Handle "exclude blocked"
if (excludeBlocked != null && excludeBlocked) {
List<String> blockedNames = ListUtils.blockedNames();
if (blockedNames != null && !blockedNames.isEmpty()) {
sql.append(" AND LCASE(name) NOT IN (?");
bindParams.add(blockedNames.get(0).toLowerCase());
for (int i = 1; i < blockedNames.size(); ++i) {
sql.append(", ?");
bindParams.add(blockedNames.get(i).toLowerCase());
}
sql.append(")");
}
}
sql.append(" ORDER BY created_when");
if (reverse != null && reverse) {
sql.append(" DESC");
@@ -419,98 +881,182 @@ public class HSQLDBArbitraryRepository implements ArbitraryRepository {
HSQLDBRepository.limitOffsetSql(sql, limit, offset);
List<ArbitraryResourceInfo> arbitraryResources = new ArrayList<>();
List<ArbitraryResourceData> arbitraryResources = new ArrayList<>();
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
if (resultSet == null)
return null;
return arbitraryResources;
do {
String nameResult = resultSet.getString(1);
Service serviceResult = Service.valueOf(resultSet.getInt(2));
String identifierResult = resultSet.getString(3);
Integer sizeResult = resultSet.getInt(4);
long dateCreated = resultSet.getLong(5);
long dateUpdated = resultSet.getLong(6);
Integer status = resultSet.getInt(5);
Long created = resultSet.getLong(6);
Long updated = resultSet.getLong(7);
// We should filter out resources without names
if (nameResult == null) {
continue;
// Optional metadata fields
String titleResult = resultSet.getString(8);
String descriptionResult = resultSet.getString(9);
String category = resultSet.getString(10);
String tag1 = resultSet.getString(11);
String tag2 = resultSet.getString(12);
String tag3 = resultSet.getString(13);
String tag4 = resultSet.getString(14);
String tag5 = resultSet.getString(15);
if (Objects.equals(identifierResult, "default")) {
// Map "default" back to null. This is optional but probably less confusing than returning "default".
identifierResult = null;
}
ArbitraryResourceInfo arbitraryResourceInfo = new ArbitraryResourceInfo();
arbitraryResourceInfo.name = nameResult;
arbitraryResourceInfo.service = serviceResult;
arbitraryResourceInfo.identifier = identifierResult;
arbitraryResourceInfo.size = Longs.valueOf(sizeResult);
arbitraryResourceInfo.created = dateCreated;
arbitraryResourceInfo.updated = dateUpdated;
ArbitraryResourceData arbitraryResourceData = new ArbitraryResourceData();
arbitraryResourceData.name = nameResult;
arbitraryResourceData.service = serviceResult;
arbitraryResourceData.identifier = identifierResult;
arbitraryResourceData.size = sizeResult;
arbitraryResourceData.created = created;
arbitraryResourceData.updated = (updated == 0) ? null : updated;
arbitraryResources.add(arbitraryResourceInfo);
if (includeStatus != null && includeStatus) {
arbitraryResourceData.setStatus(ArbitraryResourceStatus.Status.valueOf(status));
}
if (includeMetadata != null && includeMetadata) {
// TODO: we could avoid the join altogether
ArbitraryResourceMetadata metadata = new ArbitraryResourceMetadata();
metadata.setTitle(titleResult);
metadata.setDescription(descriptionResult);
metadata.setCategory(Category.uncategorizedValueOf(category));
List<String> tags = new ArrayList<>();
if (tag1 != null) tags.add(tag1);
if (tag2 != null) tags.add(tag2);
if (tag3 != null) tags.add(tag3);
if (tag4 != null) tags.add(tag4);
if (tag5 != null) tags.add(tag5);
metadata.setTags(!tags.isEmpty() ? tags : null);
if (metadata.hasMetadata()) {
arbitraryResourceData.metadata = metadata;
}
}
arbitraryResources.add(arbitraryResourceData);
} while (resultSet.next());
return arbitraryResources;
} catch (SQLException e) {
throw new DataException("Unable to fetch arbitrary transactions from repository", e);
throw new DataException("Unable to fetch arbitrary resources from repository", e);
}
}
// Arbitrary resources cache save/load
@Override
public void save(ArbitraryResourceData arbitraryResourceData) throws DataException {
HSQLDBSaver saveHelper = new HSQLDBSaver("ArbitraryResourcesCache");
// "status" isn't saved here as we update this field separately
saveHelper.bind("service", arbitraryResourceData.service.value).bind("name", arbitraryResourceData.name)
.bind("identifier", arbitraryResourceData.identifier).bind("size", arbitraryResourceData.size)
.bind("created_when", arbitraryResourceData.created).bind("updated_when", arbitraryResourceData.updated);
try {
saveHelper.execute(this.repository);
} catch (SQLException e) {
throw new DataException("Unable to save arbitrary resource info into repository", e);
}
}
@Override
public List<ArbitraryResourceNameInfo> getArbitraryResourceCreatorNames(Service service, String identifier,
boolean defaultResource, Integer limit, Integer offset, Boolean reverse) throws DataException {
StringBuilder sql = new StringBuilder(512);
sql.append("SELECT name FROM ArbitraryTransactions WHERE 1=1");
if (service != null) {
sql.append(" AND service = ");
sql.append(service.value);
public void setStatus(ArbitraryResourceData arbitraryResourceData, ArbitraryResourceStatus.Status status) throws DataException {
if (status == null) {
return;
}
String updateSql = "UPDATE ArbitraryResourcesCache SET status = ? WHERE service = ? AND LCASE(name) = ? AND LCASE(identifier) = ?";
if (defaultResource) {
// Default resource requested - use NULL identifier
// The AND ? IS NULL AND ? IS NULL is a hack to make use of the identifier params in checkedExecute()
identifier = null;
sql.append(" AND (identifier IS NULL AND ? IS NULL AND ? IS NULL)");
}
else {
// Non-default resource requested
// Use an exact match identifier, or list all if supplied identifier is null
sql.append(" AND (identifier = ? OR (? IS NULL))");
}
sql.append(" GROUP BY name ORDER BY name COLLATE SQL_TEXT_UCC_NO_PAD");
if (reverse != null && reverse) {
sql.append(" DESC");
}
HSQLDBRepository.limitOffsetSql(sql, limit, offset);
List<ArbitraryResourceNameInfo> arbitraryResources = new ArrayList<>();
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), identifier, identifier)) {
if (resultSet == null)
return null;
do {
String name = resultSet.getString(1);
// We should filter out resources without names
if (name == null) {
continue;
}
ArbitraryResourceNameInfo arbitraryResourceNameInfo = new ArbitraryResourceNameInfo();
arbitraryResourceNameInfo.name = name;
arbitraryResources.add(arbitraryResourceNameInfo);
} while (resultSet.next());
return arbitraryResources;
try {
this.repository.executeCheckedUpdate(updateSql, status.value, arbitraryResourceData.service.value, arbitraryResourceData.name.toLowerCase(), arbitraryResourceData.identifier.toLowerCase());
} catch (SQLException e) {
throw new DataException("Unable to fetch arbitrary transactions from repository", e);
throw new DataException("Unable to set status for arbitrary resource", e);
}
}
@Override
public void delete(ArbitraryResourceData arbitraryResourceData) throws DataException {
// NOTE: arbitrary metadata are deleted automatically by the database thanks to "ON DELETE CASCADE"
// in ArbitraryMetadataCache' FOREIGN KEY definition.
try {
this.repository.delete("ArbitraryResourcesCache", "service = ? AND name = ? AND identifier = ?",
arbitraryResourceData.service.value, arbitraryResourceData.name, arbitraryResourceData.identifier);
} catch (SQLException e) {
throw new DataException("Unable to delete account from repository", e);
}
}
/* Arbitrary metadata cache */
@Override
public void save(ArbitraryResourceMetadata metadata) throws DataException {
HSQLDBSaver saveHelper = new HSQLDBSaver("ArbitraryMetadataCache");
ArbitraryResourceData arbitraryResourceData = metadata.getArbitraryResourceData();
if (arbitraryResourceData == null) {
throw new DataException("Can't save metadata without a referenced resource");
}
// Trim metadata values if they are too long to fit in the db
String title = ArbitraryDataTransactionMetadata.limitTitle(metadata.getTitle());
String description = ArbitraryDataTransactionMetadata.limitDescription(metadata.getDescription());
List<String> tags = ArbitraryDataTransactionMetadata.limitTags(metadata.getTags());
String tag1 = null;
String tag2 = null;
String tag3 = null;
String tag4 = null;
String tag5 = null;
if (tags != null) {
if (tags.size() > 0) tag1 = tags.get(0);
if (tags.size() > 1) tag2 = tags.get(1);
if (tags.size() > 2) tag3 = tags.get(2);
if (tags.size() > 3) tag4 = tags.get(3);
if (tags.size() > 4) tag5 = tags.get(4);
}
String category = metadata.getCategory() != null ? metadata.getCategory().toString() : null;
saveHelper.bind("service", arbitraryResourceData.service.value).bind("name", arbitraryResourceData.name)
.bind("identifier", arbitraryResourceData.identifier).bind("title", title)
.bind("description", description).bind("category", category)
.bind("tag1", tag1).bind("tag2", tag2).bind("tag3", tag3).bind("tag4", tag4)
.bind("tag5", tag5);
try {
saveHelper.execute(this.repository);
} catch (SQLException e) {
throw new DataException("Unable to save arbitrary metadata into repository", e);
}
}
@Override
public void delete(ArbitraryResourceMetadata metadata) throws DataException {
ArbitraryResourceData arbitraryResourceData = metadata.getArbitraryResourceData();
if (arbitraryResourceData == null) {
throw new DataException("Can't delete metadata without a referenced resource");
}
try {
this.repository.delete("ArbitraryMetadataCache", "service = ? AND name = ? AND identifier = ?",
arbitraryResourceData.service.value, arbitraryResourceData.name, arbitraryResourceData.identifier);
} catch (SQLException e) {
throw new DataException("Unable to delete account from repository", e);
}
}
}

View File

@@ -901,7 +901,7 @@ public class HSQLDBDatabaseUpdates {
case 37:
// ARBITRARY transaction updates for off-chain data storage
// We may want to use a nonce rather than a transaction fee on the data chain
// We may want to use a nonce rather than a transaction fee for ARBITRARY transactions
stmt.execute("ALTER TABLE ArbitraryTransactions ADD nonce INT NOT NULL DEFAULT 0");
// We need to know the total size of the data file(s) associated with each transaction
stmt.execute("ALTER TABLE ArbitraryTransactions ADD size INT NOT NULL DEFAULT 0");
@@ -993,6 +993,60 @@ public class HSQLDBDatabaseUpdates {
stmt.execute("ALTER TABLE CancelSellNameTransactions ADD sale_price QortalAmount");
break;
case 47:
// Add `block_sequence` to the Transaction table, as the BlockTransactions table is pruned for
// older blocks and therefore the sequence becomes unavailable
LOGGER.info("Reshaping Transactions table - this can take a while...");
stmt.execute("ALTER TABLE Transactions ADD block_sequence INTEGER");
// For finding transactions by height and sequence
LOGGER.info("Adding index to Transactions table - this can take a while...");
stmt.execute("CREATE INDEX TransactionHeightSequenceIndex on Transactions (block_height, block_sequence)");
break;
case 48:
// We need to keep a local cache of arbitrary resources (items published to QDN), for easier searching.
// IMPORTANT: this is a cache of the last known state of a resource (both confirmed
// and valid unconfirmed). It cannot be assumed that all nodes will contain the same state at a
// given block height, and therefore must NOT be used for any consensus/validation code. It is
// simply a cache, to avoid having to query the raw transactions and the metadata in flat files
// when serving API requests.
// ARBITRARY transactions aren't really suitable for updating resources in the same way we'd update
// names or groups for instance, as there is no distinction between creations and updates, and metadata
// is off-chain. Plus, QDN allows (valid) unconfirmed data to be queried and viewed. It is very
// easy to keep a cache of the latest transaction's data, but anything more than that would need
// considerable thought (and most likely a rewrite).
stmt.execute("CREATE TABLE ArbitraryResourcesCache (service SMALLINT NOT NULL, "
+ "name RegisteredName NOT NULL, identifier VARCHAR(64), size INT NOT NULL, "
+ "status INTEGER, created_when EpochMillis NOT NULL, updated_when EpochMillis, "
+ "PRIMARY KEY (service, name, identifier))");
// For finding resources by service.
stmt.execute("CREATE INDEX ArbitraryResourcesServiceIndex ON ArbitraryResourcesCache (service)");
// For finding resources by name.
stmt.execute("CREATE INDEX ArbitraryResourcesNameIndex ON ArbitraryResourcesCache (name)");
// For finding resources by identifier.
stmt.execute("CREATE INDEX ArbitraryResourcesIdentifierIndex ON ArbitraryResourcesCache (identifier)");
// For finding resources by creation date (the default column when ordering).
stmt.execute("CREATE INDEX ArbitraryResourcesCreatedIndex ON ArbitraryResourcesCache (created_when)");
// Use a separate table space as this table will be very large.
stmt.execute("SET TABLE ArbitraryResourcesCache NEW SPACE");
stmt.execute("CREATE TABLE ArbitraryMetadataCache (service SMALLINT NOT NULL, "
+ "name RegisteredName NOT NULL, identifier VARCHAR(64), "
+ "title VARCHAR(80), description VARCHAR(240), category VARCHAR(64), "
+ "tag1 VARCHAR(20), tag2 VARCHAR(20), tag3 VARCHAR(20), tag4 VARCHAR(20), tag5 VARCHAR(20), "
+ "PRIMARY KEY (service, name, identifier), FOREIGN KEY (service, name, identifier) "
+ "REFERENCES ArbitraryResourcesCache (service, name, identifier) ON DELETE CASCADE)");
// For finding metadata by title.
stmt.execute("CREATE INDEX ArbitraryMetadataTitleIndex ON ArbitraryMetadataCache (title)");
// For finding arbitrary transactions by service
stmt.execute("CREATE INDEX ArbitraryServiceIndex ON ArbitraryTransactions (service)");
// For finding arbitrary transactions by identifier
stmt.execute("CREATE INDEX ArbitraryIdentifierIndex ON ArbitraryTransactions (identifier)");
break;
default:
// nothing to do
return false;

View File

@@ -103,12 +103,18 @@ public class HSQLDBNameRepository implements NameRepository {
}
}
@Override
public List<NameData> getAllNames(Integer limit, Integer offset, Boolean reverse) throws DataException {
StringBuilder sql = new StringBuilder(256);
public List<NameData> searchNames(String query, boolean prefixOnly, Integer limit, Integer offset, Boolean reverse) throws DataException {
StringBuilder sql = new StringBuilder(512);
List<Object> bindParams = new ArrayList<>();
sql.append("SELECT name, reduced_name, owner, data, registered_when, updated_when, "
+ "is_for_sale, sale_price, reference, creation_group_id FROM Names ORDER BY name");
+ "is_for_sale, sale_price, reference, creation_group_id FROM Names "
+ "WHERE LCASE(name) LIKE ? ORDER BY name");
// Search anywhere in the name, unless "prefixOnly" has been requested
// Note that without prefixOnly it will bypass any indexes
String queryWildcard = prefixOnly ? String.format("%s%%", query.toLowerCase()) : String.format("%%%s%%", query.toLowerCase());
bindParams.add(queryWildcard);
if (reverse != null && reverse)
sql.append(" DESC");
@@ -117,7 +123,64 @@ public class HSQLDBNameRepository implements NameRepository {
List<NameData> names = new ArrayList<>();
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString())) {
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
if (resultSet == null)
return names;
do {
String name = resultSet.getString(1);
String reducedName = resultSet.getString(2);
String owner = resultSet.getString(3);
String data = resultSet.getString(4);
long registered = resultSet.getLong(5);
// Special handling for possibly-NULL "updated" column
Long updated = resultSet.getLong(6);
if (updated == 0 && resultSet.wasNull())
updated = null;
boolean isForSale = resultSet.getBoolean(7);
Long salePrice = resultSet.getLong(8);
if (salePrice == 0 && resultSet.wasNull())
salePrice = null;
byte[] reference = resultSet.getBytes(9);
int creationGroupId = resultSet.getInt(10);
names.add(new NameData(name, reducedName, owner, data, registered, updated, isForSale, salePrice, reference, creationGroupId));
} while (resultSet.next());
return names;
} catch (SQLException e) {
throw new DataException("Unable to search names in repository", e);
}
}
@Override
public List<NameData> getAllNames(Long after, Integer limit, Integer offset, Boolean reverse) throws DataException {
StringBuilder sql = new StringBuilder(256);
List<Object> bindParams = new ArrayList<>();
sql.append("SELECT name, reduced_name, owner, data, registered_when, updated_when, "
+ "is_for_sale, sale_price, reference, creation_group_id FROM Names");
if (after != null) {
sql.append(" WHERE registered_when > ? OR updated_when > ?");
bindParams.add(after);
bindParams.add(after);
}
sql.append(" ORDER BY name");
if (reverse != null && reverse)
sql.append(" DESC");
HSQLDBRepository.limitOffsetSql(sql, limit, offset);
List<NameData> names = new ArrayList<>();
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
if (resultSet == null)
return names;

View File

@@ -21,6 +21,55 @@ public class HSQLDBVotingRepository implements VotingRepository {
// Polls
@Override
public List<PollData> getAllPolls(Integer limit, Integer offset, Boolean reverse) throws DataException {
StringBuilder sql = new StringBuilder(512);
sql.append("SELECT poll_name, description, creator, owner, published_when FROM Polls ORDER BY poll_name");
if (reverse != null && reverse)
sql.append(" DESC");
HSQLDBRepository.limitOffsetSql(sql, limit, offset);
List<PollData> polls = new ArrayList<>();
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString())) {
if (resultSet == null)
return polls;
do {
String pollName = resultSet.getString(1);
String description = resultSet.getString(2);
byte[] creatorPublicKey = resultSet.getBytes(3);
String owner = resultSet.getString(4);
long published = resultSet.getLong(5);
String optionsSql = "SELECT option_name FROM PollOptions WHERE poll_name = ? ORDER BY option_index ASC";
try (ResultSet optionsResultSet = this.repository.checkedExecute(optionsSql, pollName)) {
if (optionsResultSet == null)
return null;
List<PollOptionData> pollOptions = new ArrayList<>();
// NOTE: do-while because checkedExecute() above has already called rs.next() for us
do {
String optionName = optionsResultSet.getString(1);
pollOptions.add(new PollOptionData(optionName));
} while (optionsResultSet.next());
polls.add(new PollData(creatorPublicKey, owner, pollName, description, pollOptions, published));
}
} while (resultSet.next());
return polls;
} catch (SQLException e) {
throw new DataException("Unable to fetch polls from repository", e);
}
}
@Override
public PollData fromPollName(String pollName) throws DataException {
String sql = "SELECT description, creator, owner, published_when FROM Polls WHERE poll_name = ?";

View File

@@ -194,8 +194,7 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
@Override
public TransactionData fromHeightAndSequence(int height, int sequence) throws DataException {
String sql = "SELECT transaction_signature FROM BlockTransactions JOIN Blocks ON signature = block_signature "
+ "WHERE height = ? AND sequence = ?";
String sql = "SELECT signature FROM Transactions WHERE block_height = ? AND block_sequence = ?";
try (ResultSet resultSet = this.repository.checkedExecute(sql, height, sequence)) {
if (resultSet == null)
@@ -657,8 +656,13 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
List<Object> bindParams) throws DataException {
List<byte[]> signatures = new ArrayList<>();
String txTypeClassName = "";
if (txType != null) {
txTypeClassName = txType.className;
}
StringBuilder sql = new StringBuilder(1024);
sql.append(String.format("SELECT signature FROM %sTransactions", txType.className));
sql.append(String.format("SELECT signature FROM %sTransactions", txTypeClassName));
if (!whereClauses.isEmpty()) {
sql.append(" WHERE ");
@@ -690,6 +694,53 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
}
}
public List<byte[]> getSignaturesMatchingCustomCriteria(TransactionType txType, List<String> whereClauses,
List<Object> bindParams, Integer limit) throws DataException {
List<byte[]> signatures = new ArrayList<>();
String txTypeClassName = "";
if (txType != null) {
txTypeClassName = txType.className;
}
StringBuilder sql = new StringBuilder(1024);
sql.append(String.format("SELECT signature FROM %sTransactions", txTypeClassName));
if (!whereClauses.isEmpty()) {
sql.append(" WHERE ");
final int whereClausesSize = whereClauses.size();
for (int wci = 0; wci < whereClausesSize; ++wci) {
if (wci != 0)
sql.append(" AND ");
sql.append(whereClauses.get(wci));
}
}
if (limit != null) {
sql.append(" LIMIT ?");
bindParams.add(limit);
}
LOGGER.trace(() -> String.format("Transaction search SQL: %s", sql));
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
if (resultSet == null)
return signatures;
do {
byte[] signature = resultSet.getBytes(1);
signatures.add(signature);
} while (resultSet.next());
return signatures;
} catch (SQLException e) {
throw new DataException("Unable to fetch matching transaction signatures from repository", e);
}
}
@Override
public byte[] getLatestAutoUpdateTransaction(TransactionType txType, int txGroupId, Integer service) throws DataException {
StringBuilder sql = new StringBuilder(1024);
@@ -1378,8 +1429,10 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
}
@Override
public List<TransactionData> getUnconfirmedTransactions(EnumSet<TransactionType> excludedTxTypes) throws DataException {
public List<TransactionData> getUnconfirmedTransactions(EnumSet<TransactionType> excludedTxTypes, Integer limit) throws DataException {
StringBuilder sql = new StringBuilder(1024);
List<Object> bindParams = new ArrayList<>();
sql.append("SELECT signature FROM UnconfirmedTransactions ");
sql.append("JOIN Transactions USING (signature) ");
sql.append("WHERE type NOT IN (");
@@ -1395,12 +1448,17 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
}
sql.append(")");
sql.append("ORDER BY created_when, signature");
sql.append("ORDER BY created_when, signature ");
if (limit != null) {
sql.append("LIMIT ?");
bindParams.add(limit);
}
List<TransactionData> transactions = new ArrayList<>();
// Find transactions with no corresponding row in BlockTransactions
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString())) {
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
if (resultSet == null)
return transactions;
@@ -1444,6 +1502,19 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
}
}
@Override
public void updateBlockSequence(byte[] signature, Integer blockSequence) throws DataException {
HSQLDBSaver saver = new HSQLDBSaver("Transactions");
saver.bind("signature", signature).bind("block_sequence", blockSequence);
try {
saver.execute(repository);
} catch (SQLException e) {
throw new DataException("Unable to update transaction's block sequence in repository", e);
}
}
@Override
public void updateApprovalHeight(byte[] signature, Integer approvalHeight) throws DataException {
HSQLDBSaver saver = new HSQLDBSaver("Transactions");

View File

@@ -47,6 +47,9 @@ public class Settings {
private static final int MAINNET_GATEWAY_PORT = 80;
private static final int TESTNET_GATEWAY_PORT = 8080;
private static final int MAINNET_DEV_PROXY_PORT = 12393;
private static final int TESTNET_DEV_PROXY_PORT = 62393;
private static final Logger LOGGER = LogManager.getLogger(Settings.class);
private static final String SETTINGS_FILENAME = "settings.json";
@@ -61,6 +64,7 @@ public class Settings {
// Common to all networking (API/P2P)
private String bindAddress = "::"; // Use IPv6 wildcard to listen on all local addresses
private String bindAddressFallback = "0.0.0.0"; // Some systems are unable to bind using IPv6
// UI servers
private int uiPort = 12388;
@@ -106,6 +110,11 @@ public class Settings {
private boolean gatewayLoggingEnabled = false;
private boolean gatewayLoopbackEnabled = false;
// Developer Proxy
private Integer devProxyPort;
private boolean devProxyLoggingEnabled = false;
// Specific to this node
private boolean wipeUnconfirmedOnStart = false;
/** Maximum number of unconfirmed transactions allowed per account */
@@ -137,6 +146,9 @@ public class Settings {
/* How many blocks to cache locally. Defaulted to 10, which covers a typical Synchronizer request + a few spare */
private int blockCacheSize = 10;
/** Maximum number of transactions for the block minter to include in a block */
private int maxTransactionsPerBlock = 25;
/** How long to keep old, full, AT state data (ms). */
private long atStatesMaxLifetime = 5 * 24 * 60 * 60 * 1000L; // milliseconds
/** How often to attempt AT state trimming (ms). */
@@ -180,7 +192,7 @@ public class Settings {
/** How often to attempt archiving (ms). */
private long archiveInterval = 7171L; // milliseconds
/** Serialization version to use when building an archive */
private int defaultArchiveVersion = 1;
private int defaultArchiveVersion = 2;
/** Whether to automatically bootstrap instead of syncing from genesis */
@@ -200,25 +212,25 @@ public class Settings {
/** Whether to attempt to open the listen port via UPnP */
private boolean uPnPEnabled = true;
/** Minimum number of peers to allow block minting / synchronization. */
private int minBlockchainPeers = 5;
private int minBlockchainPeers = 3;
/** Target number of outbound connections to peers we should make. */
private int minOutboundPeers = 16;
/** Maximum number of peer connections we allow. */
private int maxPeers = 36;
private int maxPeers = 40;
/** Number of slots to reserve for short-lived QDN data transfers */
private int maxDataPeers = 4;
/** Maximum number of threads for network engine. */
private int maxNetworkThreadPoolSize = 32;
private int maxNetworkThreadPoolSize = 120;
/** Maximum number of threads for network proof-of-work compute, used during handshaking. */
private int networkPoWComputePoolSize = 2;
/** Maximum number of retry attempts if a peer fails to respond with the requested data */
private int maxRetries = 2;
/** The number of seconds of no activity before recovery mode begins */
public long recoveryModeTimeout = 10 * 60 * 1000L;
public long recoveryModeTimeout = 24 * 60 * 60 * 1000L;
/** Minimum peer version number required in order to sync with them */
private String minPeerVersion = "3.8.7";
private String minPeerVersion = "4.1.2";
/** Whether to allow connections with peers below minPeerVersion
* If true, we won't sync with them but they can still sync with us, and will show in the peers list
* If false, sync will be blocked both ways, and they will not appear in the peers list */
@@ -252,6 +264,9 @@ public class Settings {
/** Whether to show SysTray pop-up notifications when trade-bot entries change state */
private boolean tradebotSystrayEnabled = false;
/** Maximum buy attempts for each trade offer before it is considered failed, and hidden from the list */
private int maxTradeOfferAttempts = 3;
/** Wallets path - used for storing encrypted wallet caches for coins that require them */
private String walletsPath = "wallets";
@@ -263,7 +278,7 @@ public class Settings {
/** Repository storage path. */
private String repositoryPath = "db";
/** Repository connection pool size. Needs to be a bit bigger than maxNetworkThreadPoolSize */
private int repositoryConnectionPoolSize = 100;
private int repositoryConnectionPoolSize = 240;
private List<String> fixedNetwork;
// Export/import
@@ -504,6 +519,9 @@ public class Settings {
if (this.minBlockchainPeers < 1 && !singleNodeTestnet)
throwValidationError("minBlockchainPeers must be at least 1");
if (this.topOnly)
throwValidationError("topOnly mode is no longer supported");
if (this.apiKey != null && this.apiKey.trim().length() < 8)
throwValidationError("apiKey must be at least 8 characters");
@@ -642,6 +660,18 @@ public class Settings {
}
public int getDevProxyPort() {
if (this.devProxyPort != null)
return this.devProxyPort;
return this.isTestNet ? TESTNET_DEV_PROXY_PORT : MAINNET_DEV_PROXY_PORT;
}
public boolean isDevProxyLoggingEnabled() {
return this.devProxyLoggingEnabled;
}
public boolean getWipeUnconfirmedOnStart() {
return this.wipeUnconfirmedOnStart;
}
@@ -666,6 +696,10 @@ public class Settings {
return this.blockCacheSize;
}
public int getMaxTransactionsPerBlock() {
return this.maxTransactionsPerBlock;
}
public boolean isTestNet() {
return this.isTestNet;
}
@@ -689,6 +723,10 @@ public class Settings {
return this.bindAddress;
}
public String getBindAddressFallback() {
return this.bindAddressFallback;
}
public boolean isUPnPEnabled() {
return this.uPnPEnabled;
}
@@ -766,6 +804,10 @@ public class Settings {
return this.pirateChainNet;
}
public int getMaxTradeOfferAttempts() {
return this.maxTradeOfferAttempts;
}
public String getWalletsPath() {
return this.walletsPath;
}

View File

@@ -1,18 +1,26 @@
package org.qortal.transaction;
import java.util.Arrays;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qortal.account.Account;
import org.qortal.arbitrary.ArbitraryDataResource;
import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata;
import org.qortal.arbitrary.misc.Service;
import org.qortal.block.BlockChain;
import org.qortal.controller.arbitrary.ArbitraryDataCacheManager;
import org.qortal.controller.arbitrary.ArbitraryDataManager;
import org.qortal.controller.arbitrary.ArbitraryDataStorageManager;
import org.qortal.controller.repository.NamesDatabaseIntegrityCheck;
import org.qortal.crypto.Crypto;
import org.qortal.crypto.MemoryPoW;
import org.qortal.data.PaymentData;
import org.qortal.data.arbitrary.ArbitraryResourceData;
import org.qortal.data.arbitrary.ArbitraryResourceMetadata;
import org.qortal.data.arbitrary.ArbitraryResourceStatus;
import org.qortal.data.naming.NameData;
import org.qortal.data.transaction.ArbitraryTransactionData;
import org.qortal.data.transaction.TransactionData;
@@ -20,6 +28,7 @@ import org.qortal.payment.Payment;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.arbitrary.ArbitraryDataFile;
import org.qortal.repository.RepositoryManager;
import org.qortal.transform.TransformationException;
import org.qortal.transform.Transformer;
import org.qortal.transform.transaction.ArbitraryTransactionTransformer;
@@ -29,6 +38,8 @@ import org.qortal.utils.NTP;
public class ArbitraryTransaction extends Transaction {
private static final Logger LOGGER = LogManager.getLogger(ArbitraryTransaction.class);
// Properties
private ArbitraryTransactionData arbitraryTransactionData;
@@ -240,14 +251,8 @@ public class ArbitraryTransaction extends Transaction {
// We may need to move files from the misc_ folder
ArbitraryTransactionUtils.checkAndRelocateMiscFiles(arbitraryTransactionData);
// If the data is local, we need to perform a few actions
if (isDataLocal()) {
// We have the data for this transaction, so invalidate the cache
if (arbitraryTransactionData.getName() != null) {
ArbitraryDataManager.getInstance().invalidateCache(arbitraryTransactionData);
}
}
// Update caches
updateCaches();
}
@Override
@@ -267,6 +272,35 @@ public class ArbitraryTransaction extends Transaction {
public void process() throws DataException {
// Wrap and delegate payment processing to Payment class.
new Payment(this.repository).process(arbitraryTransactionData.getSenderPublicKey(), arbitraryTransactionData.getPayments());
// Update caches
this.updateCaches();
}
private void updateCaches() {
// Very important to use a separate repository instance from the one being used for validation/processing
try (final Repository repository = RepositoryManager.getRepository()) {
// If the data is local, we need to perform a few actions
if (isDataLocal()) {
// We have the data for this transaction, so invalidate the file cache
if (arbitraryTransactionData.getName() != null) {
ArbitraryDataManager.getInstance().invalidateCache(arbitraryTransactionData);
}
}
// Add/update arbitrary resource caches, but don't update the status as this involves time-consuming
// disk reads, and is more prone to failure. The status will be updated on metadata retrieval, or when
// accessing the resource.
this.updateArbitraryResourceCache(repository);
this.updateArbitraryMetadataCache(repository);
repository.saveChanges();
} catch (Exception e) {
// Log and ignore all exceptions. The cache is updated from other places too, and can be rebuilt if needed.
LOGGER.info("Unable to update arbitrary caches", e);
}
}
@Override
@@ -304,4 +338,166 @@ public class ArbitraryTransaction extends Transaction {
return null;
}
/**
* Update the arbitrary resources cache.
* This finds the latest transaction and replaces the
* majority of the data in the cache. The current
* transaction is used for the created time,
* if it has a lower timestamp than the existing value.
* It's also used to identify the correct
* service/name/identifier combination.
*
* @throws DataException
*/
public void updateArbitraryResourceCache(Repository repository) throws DataException {
// Don't cache resources without a name (such as auto updates)
if (arbitraryTransactionData.getName() == null) {
return;
}
Service service = arbitraryTransactionData.getService();
String name = arbitraryTransactionData.getName();
String identifier = arbitraryTransactionData.getIdentifier();
if (service == null) {
// Unsupported service - ignore this resource
return;
}
// In the cache we store null identifiers as "default", as it is part of the primary key
if (identifier == null) {
identifier = "default";
}
ArbitraryResourceData arbitraryResourceData = new ArbitraryResourceData();
arbitraryResourceData.service = service;
arbitraryResourceData.name = name;
arbitraryResourceData.identifier = identifier;
// Get the latest transaction
ArbitraryTransactionData latestTransactionData = repository.getArbitraryRepository().getLatestTransaction(arbitraryTransactionData.getName(), arbitraryTransactionData.getService(), null, arbitraryTransactionData.getIdentifier());
if (latestTransactionData == null) {
// We don't have a latest transaction, so delete from cache
repository.getArbitraryRepository().delete(arbitraryResourceData);
return;
}
// Get existing cached entry if it exists
ArbitraryResourceData existingArbitraryResourceData = repository.getArbitraryRepository()
.getArbitraryResource(service, name, identifier);
// Check for existing cached data
if (existingArbitraryResourceData == null) {
// Nothing exists yet, so set creation date from the current transaction (it will be reduced later if needed)
arbitraryResourceData.created = arbitraryTransactionData.getTimestamp();
arbitraryResourceData.updated = null;
}
else {
// An entry already exists - update created time from current transaction if this is older
arbitraryResourceData.created = Math.min(existingArbitraryResourceData.created, arbitraryTransactionData.getTimestamp());
// Set updated time to the latest transaction's timestamp, unless it matches the creation time
if (existingArbitraryResourceData.created == latestTransactionData.getTimestamp()) {
// Latest transaction matches created time, so it hasn't been updated
arbitraryResourceData.updated = null;
}
else {
arbitraryResourceData.updated = latestTransactionData.getTimestamp();
}
}
arbitraryResourceData.size = latestTransactionData.getSize();
// Save
repository.getArbitraryRepository().save(arbitraryResourceData);
}
public void updateArbitraryResourceStatus(Repository repository) throws DataException {
// Don't cache resources without a name (such as auto updates)
if (arbitraryTransactionData.getName() == null) {
return;
}
Service service = arbitraryTransactionData.getService();
String name = arbitraryTransactionData.getName();
String identifier = arbitraryTransactionData.getIdentifier();
if (service == null) {
// Unsupported service - ignore this resource
return;
}
// In the cache we store null identifiers as "default", as it is part of the primary key
if (identifier == null) {
identifier = "default";
}
ArbitraryResourceData arbitraryResourceData = new ArbitraryResourceData();
arbitraryResourceData.service = service;
arbitraryResourceData.name = name;
arbitraryResourceData.identifier = identifier;
// Update status
ArbitraryDataResource resource = new ArbitraryDataResource(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
ArbitraryResourceStatus arbitraryResourceStatus = resource.getStatus(repository);
ArbitraryResourceStatus.Status status = arbitraryResourceStatus != null ? arbitraryResourceStatus.getStatus() : null;
repository.getArbitraryRepository().setStatus(arbitraryResourceData, status);
}
public void updateArbitraryMetadataCache(Repository repository) throws DataException {
// Get the latest transaction
ArbitraryTransactionData latestTransactionData = repository.getArbitraryRepository().getLatestTransaction(arbitraryTransactionData.getName(), arbitraryTransactionData.getService(), null, arbitraryTransactionData.getIdentifier());
if (latestTransactionData == null) {
// We don't have a latest transaction, so give up
return;
}
Service service = latestTransactionData.getService();
String name = latestTransactionData.getName();
String identifier = latestTransactionData.getIdentifier();
if (service == null) {
// Unsupported service - ignore this resource
return;
}
// In the cache we store null identifiers as "default", as it is part of the primary key
if (identifier == null) {
identifier = "default";
}
ArbitraryResourceData arbitraryResourceData = new ArbitraryResourceData();
arbitraryResourceData.service = service;
arbitraryResourceData.name = name;
arbitraryResourceData.identifier = identifier;
// Update metadata for latest transaction if it is local
if (latestTransactionData.getMetadataHash() != null) {
ArbitraryDataFile metadataFile = ArbitraryDataFile.fromHash(latestTransactionData.getMetadataHash(), latestTransactionData.getSignature());
if (metadataFile.exists()) {
ArbitraryDataTransactionMetadata transactionMetadata = new ArbitraryDataTransactionMetadata(metadataFile.getFilePath());
try {
transactionMetadata.read();
ArbitraryResourceMetadata metadata = new ArbitraryResourceMetadata();
metadata.setArbitraryResourceData(arbitraryResourceData);
metadata.setTitle(transactionMetadata.getTitle());
metadata.setDescription(transactionMetadata.getDescription());
metadata.setCategory(transactionMetadata.getCategory());
metadata.setTags(transactionMetadata.getTags());
repository.getArbitraryRepository().save(metadata);
} catch (IOException e) {
// Ignore, as we can add it again later
}
} else {
// We don't have a local copy of this metadata file, so delete it from the cache
// It will be re-added if the file later arrives via the network
ArbitraryResourceMetadata metadata = new ArbitraryResourceMetadata();
metadata.setArbitraryResourceData(arbitraryResourceData);
repository.getArbitraryRepository().delete(metadata);
}
}
}
}

View File

@@ -8,6 +8,7 @@ import java.util.function.Predicate;
import org.qortal.account.Account;
import org.qortal.account.PublicKeyAccount;
import org.qortal.asset.Asset;
import org.qortal.controller.arbitrary.ArbitraryDataStorageManager;
import org.qortal.crypto.Crypto;
import org.qortal.crypto.MemoryPoW;
import org.qortal.data.naming.NameData;
@@ -22,6 +23,7 @@ import org.qortal.settings.Settings;
import org.qortal.transform.TransformationException;
import org.qortal.transform.transaction.ChatTransactionTransformer;
import org.qortal.transform.transaction.TransactionTransformer;
import org.qortal.utils.ListUtils;
import org.qortal.utils.NTP;
public class ChatTransaction extends Transaction {
@@ -156,8 +158,7 @@ public class ChatTransaction extends Transaction {
}
// Check for blocked author by address
ResourceListManager listManager = ResourceListManager.getInstance();
if (listManager.listContains("blockedAddresses", this.chatTransactionData.getSender(), true)) {
if (ListUtils.isAddressBlocked(this.chatTransactionData.getSender())) {
return ValidationResult.ADDRESS_BLOCKED;
}
@@ -166,7 +167,7 @@ public class ChatTransaction extends Transaction {
if (names != null && names.size() > 0) {
for (NameData nameData : names) {
if (nameData != null && nameData.getName() != null) {
if (listManager.listContains("blockedNames", nameData.getName(), false)) {
if (ListUtils.isNameBlocked(nameData.getName())) {
return ValidationResult.NAME_BLOCKED;
}
}

View File

@@ -641,7 +641,7 @@ public abstract class Transaction {
BlockData latestBlockData = repository.getBlockRepository().getLastBlock();
EnumSet<TransactionType> excludedTxTypes = EnumSet.of(TransactionType.CHAT, TransactionType.PRESENCE);
List<TransactionData> unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(excludedTxTypes);
List<TransactionData> unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(excludedTxTypes, null);
unconfirmedTransactions.sort(getDataComparator());

View File

@@ -4,10 +4,8 @@ import org.apache.commons.lang3.ArrayUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qortal.arbitrary.*;
import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata;
import org.qortal.arbitrary.misc.Service;
import org.qortal.data.arbitrary.ArbitraryResourceInfo;
import org.qortal.data.arbitrary.ArbitraryResourceMetadata;
import org.qortal.data.arbitrary.ArbitraryResourceData;
import org.qortal.data.arbitrary.ArbitraryResourceStatus;
import org.qortal.data.transaction.ArbitraryTransactionData;
import org.qortal.data.transaction.TransactionData;
@@ -258,8 +256,7 @@ public class ArbitraryTransactionUtils {
"chunks if needed", Base58.encode(completeHash));
ArbitraryTransactionUtils.deleteCompleteFile(arbitraryTransactionData, now, cleanupAfter);
}
else {
} else {
// File might be in use. It's best to leave it and it it will be cleaned up later.
}
}
@@ -271,6 +268,7 @@ public class ArbitraryTransactionUtils {
* When first uploaded, files go into a _misc folder as they are not yet associated with a
* transaction signature. Once the transaction is broadcast, they need to be moved to the
* correct location, keyed by the transaction signature.
*
* @param arbitraryTransactionData
* @return
* @throws DataException
@@ -356,8 +354,7 @@ public class ArbitraryTransactionUtils {
file.createNewFile();
}
}
}
catch (DataException | IOException e) {
} catch (DataException | IOException e) {
LOGGER.info("Unable to check and relocate all files for signature {}: {}",
Base58.encode(arbitraryTransactionData.getSignature()), e.getMessage());
}
@@ -366,7 +363,7 @@ public class ArbitraryTransactionUtils {
}
public static List<ArbitraryTransactionData> limitOffsetTransactions(List<ArbitraryTransactionData> transactions,
Integer limit, Integer offset) {
Integer limit, Integer offset) {
if (limit != null && limit == 0) {
limit = null;
}
@@ -389,18 +386,19 @@ public class ArbitraryTransactionUtils {
/**
* Lookup status of resource
*
* @param service
* @param name
* @param identifier
* @param build
* @return
*/
public static ArbitraryResourceStatus getStatus(Service service, String name, String identifier, Boolean build) {
public static ArbitraryResourceStatus getStatus(Service service, String name, String identifier, Boolean build, boolean updateCache) {
// If "build" has been specified, build the resource before returning its status
if (build != null && build == true) {
ArbitraryDataReader reader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
try {
ArbitraryDataReader reader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
if (!reader.isBuilding()) {
reader.loadSynchronously(false);
}
@@ -410,44 +408,6 @@ public class ArbitraryTransactionUtils {
}
ArbitraryDataResource resource = new ArbitraryDataResource(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
return resource.getStatus(false);
return resource.getStatusAndUpdateCache(updateCache);
}
public static List<ArbitraryResourceInfo> addStatusToResources(List<ArbitraryResourceInfo> resources) {
// Determine and add the status of each resource
List<ArbitraryResourceInfo> updatedResources = new ArrayList<>();
for (ArbitraryResourceInfo resourceInfo : resources) {
try {
ArbitraryDataResource resource = new ArbitraryDataResource(resourceInfo.name, ArbitraryDataFile.ResourceIdType.NAME,
resourceInfo.service, resourceInfo.identifier);
ArbitraryResourceStatus status = resource.getStatus(true);
if (status != null) {
resourceInfo.status = status;
}
updatedResources.add(resourceInfo);
} catch (Exception e) {
// Catch and log all exceptions, since some systems are experiencing 500 errors when including statuses
LOGGER.info("Caught exception when adding status to resource {}: {}", resourceInfo, e.toString());
}
}
return updatedResources;
}
public static List<ArbitraryResourceInfo> addMetadataToResources(List<ArbitraryResourceInfo> resources) {
// Add metadata fields to each resource if they exist
List<ArbitraryResourceInfo> updatedResources = new ArrayList<>();
for (ArbitraryResourceInfo resourceInfo : resources) {
ArbitraryDataResource resource = new ArbitraryDataResource(resourceInfo.name, ArbitraryDataFile.ResourceIdType.NAME,
resourceInfo.service, resourceInfo.identifier);
ArbitraryDataTransactionMetadata transactionMetadata = resource.getLatestTransactionMetadata();
ArbitraryResourceMetadata resourceMetadata = ArbitraryResourceMetadata.fromTransactionMetadata(transactionMetadata, false);
if (resourceMetadata != null) {
resourceInfo.metadata = resourceMetadata;
}
updatedResources.add(resourceInfo);
}
return updatedResources;
}
}

View File

@@ -1,5 +1,7 @@
package org.qortal.utils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qortal.data.at.ATStateData;
import org.qortal.data.block.BlockData;
import org.qortal.data.transaction.TransactionData;
@@ -12,6 +14,8 @@ import java.util.List;
public class BlockArchiveUtils {
private static final Logger LOGGER = LogManager.getLogger(BlockArchiveUtils.class);
/**
* importFromArchive
* <p>
@@ -87,7 +91,8 @@ public class BlockArchiveUtils {
} catch (DataException e) {
repository.discardChanges();
throw new IllegalStateException("Unable to import blocks from archive");
LOGGER.info("Unable to import blocks from archive", e);
throw(e);
}
}
repository.saveChanges();

View File

@@ -228,12 +228,18 @@ public class FilesystemUtils {
* @throws IOException
*/
public static byte[] getSingleFileContents(Path path) throws IOException {
return getSingleFileContents(path, null);
}
public static byte[] getSingleFileContents(Path path, Integer maxLength) throws IOException {
byte[] data = null;
// TODO: limit the file size that can be loaded into memory
// If the path is a file, read the contents directly
if (path.toFile().isFile()) {
data = Files.readAllBytes(path);
int fileSize = (int)path.toFile().length();
maxLength = maxLength != null ? Math.min(maxLength, fileSize) : fileSize;
data = FilesystemUtils.readFromFile(path.toString(), 0, maxLength);
}
// Or if it's a directory, only load file contents if there is a single file inside it
@@ -242,7 +248,9 @@ public class FilesystemUtils {
if (files.length == 1) {
Path filePath = Paths.get(path.toString(), files[0]);
if (filePath.toFile().isFile()) {
data = Files.readAllBytes(filePath);
int fileSize = (int)filePath.toFile().length();
maxLength = maxLength != null ? Math.min(maxLength, fileSize) : fileSize;
data = FilesystemUtils.readFromFile(filePath.toString(), 0, maxLength);
}
}
}

View File

@@ -0,0 +1,38 @@
package org.qortal.utils;
import org.qortal.list.ResourceListManager;
import java.util.List;
public class ListUtils {
/* Blocking */
public static List<String> blockedNames() {
return ResourceListManager.getInstance().getStringsInListsWithPrefix("blockedNames");
}
public static boolean isNameBlocked(String name) {
return ResourceListManager.getInstance().listWithPrefixContains("blockedNames", name, false);
}
public static boolean isAddressBlocked(String address) {
return ResourceListManager.getInstance().listWithPrefixContains("blockedAddresses", address, true);
}
/* Following */
public static List<String> followedNames() {
return ResourceListManager.getInstance().getStringsInListsWithPrefix("followedNames");
}
public static boolean isFollowingName(String name) {
return ResourceListManager.getInstance().listWithPrefixContains("followedNames", name, false);
}
public static int followedNamesCount() {
return ListUtils.followedNames().size();
}
}

Some files were not shown because too many files have changed in this diff Show More