mirror of
				https://github.com/Qortal/qortal.git
				synced 2025-10-31 01:37:03 +00:00 
			
		
		
		
	Compare commits
	
		
			1131 Commits
		
	
	
		
			synchroniz
			...
			block-mint
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | de5f31ac58 | ||
|  | 41c4e0c83e | ||
|  | 99f6bb5ac6 | ||
|  | 3e0306f646 | ||
|  | 84e4f9a1c1 | ||
|  | cd5ce6dd5e | ||
|  | 9ec4e24ef6 | ||
|  | fa447ccded | ||
|  | ef838627c4 | ||
|  | b8aaf14cdc | ||
|  | 2740543abf | ||
|  | 3c526db52e | ||
|  | cfe0414d96 | ||
|  | 08e06ba11a | ||
|  | 8c03164ea5 | ||
|  | 0fe2f226bc | ||
|  | 55b5702158 | ||
|  | a4cbbb3868 | ||
|  | 816b01c1fc | ||
|  | 483e7549f8 | ||
|  | 60d71863dc | ||
|  | 170244e679 | ||
|  | 472e1da792 | ||
|  | cbf03d58c8 | ||
|  | ba41d84af9 | ||
|  | 98831a9449 | ||
|  | 9692539a3f | ||
|  | 76df332b57 | ||
|  | c6405340bc | ||
|  | 775e3c065e | ||
|  | 8937b3ec86 | ||
|  | 3fbb86fded | ||
|  | 0cf2f7f254 | ||
|  | 9e571b87e8 | ||
|  | 23bafb6233 | ||
|  | 6dec65c5d9 | ||
|  | 4e59eb8958 | ||
|  | 756d5e685a | ||
|  | f52530b848 | ||
|  | c2bf37b878 | ||
|  | 98a2dd04b8 | ||
|  | 694ea689c8 | ||
|  | 618aaaf243 | ||
|  | 9224ffbf73 | ||
|  | 892612c084 | ||
|  | 077165b807 | ||
|  | 7994fc6407 | ||
|  | d98df3e47d | ||
|  | 1064b1a08b | ||
|  | 0b7a7ed0f1 | ||
|  | 114b1aac76 | ||
|  | 6d06953a0e | ||
|  | 0430fc8a47 | ||
|  | 7338f5f985 | ||
|  | 640bcdd504 | ||
|  | c9d5d996e5 | ||
|  | 710befec0c | ||
|  | 8ccb158241 | ||
|  | 97199d9b91 | ||
|  | 5a8b895475 | ||
|  | 6c9600cda0 | ||
|  | 82fa6a4fd8 | ||
|  | 45f2d7ab70 | ||
|  | 33731b969a | ||
|  | 40a8cdc71f | ||
|  | cbe83987d8 | ||
|  | 01e4bf3a77 | ||
|  | b198a8ea07 | ||
|  | e2e87766fa | ||
|  | f005a0975d | ||
|  | 5700369935 | ||
|  | 8a1fb6fe4e | ||
|  | 5b788dad2f | ||
|  | fa2bd40d5f | ||
|  | 074bfadb28 | ||
|  | bd60c793be | ||
|  | 90f3d2568a | ||
|  | c73cdefe6f | ||
|  | c5093168b1 | ||
|  | a35e309a2f | ||
|  | d4ff7bbe4d | ||
|  | d4d73fc5fc | ||
|  | bb35030112 | ||
|  | 7aed0354f1 | ||
|  | c4f763960c | ||
|  | c5182a4589 | ||
|  | fc1a376fbd | ||
|  | 27387a134f | ||
|  | be7bb2df9e | ||
|  | 72a291a54a | ||
|  | b1342d84fb | ||
|  | cdd57190ce | ||
|  | d200a098cd | ||
|  | a0ed3f53a4 | ||
|  | e5c12b18af | ||
|  | 7808a1553e | ||
|  | a0ba016171 | ||
|  | 344704b6bf | ||
|  | 3303e41a39 | ||
|  | 4e71ae0e59 | ||
|  | 9daf7a6668 | ||
|  | a2b2b63932 | ||
|  | af06774ba6 | ||
|  | 311fe98f44 | ||
|  | 6f7c8d96b9 | ||
|  | ff6ec83b1c | ||
|  | ea10eec926 | ||
|  | be561a1609 | ||
|  | 6f724f648d | ||
|  | 048776e090 | ||
|  | a7c02733ec | ||
|  | 59346db427 | ||
|  | 25efee55b8 | ||
|  | b30445c5f8 | ||
|  | d105613e51 | ||
|  | ef43e78d54 | ||
|  | 6f61fbb127 | ||
|  | 9f9b7cab99 | ||
|  | f129e16878 | ||
|  | 8a42dce763 | ||
|  | 6423d5e474 | ||
|  | 6e91157dcf | ||
|  | 85c61c1bc1 | ||
|  | 54af36fb85 | ||
|  | fcdcc939e6 | ||
|  | 13450d5afa | ||
|  | 5e1e653095 | ||
|  | e8fabcb449 | ||
|  | a4ce41ed39 | ||
|  | 1b42062d57 | ||
|  | c2a4b01a9c | ||
|  | 47e763b0cf | ||
|  | 0278f6c9f2 | ||
|  | d96bc14516 | ||
|  | 318f433f22 | ||
|  | cfc80cb9b0 | ||
|  | 01c6149422 | ||
|  | 6f80a6c08a | ||
|  | 8fb2d38cd1 | ||
|  | 5018d27c25 | ||
|  | 1d77101253 | ||
|  | 1ddd468c1f | ||
|  | f05cd9ea51 | ||
|  | 70c00a4150 | ||
|  | d296029e8e | ||
|  | e257fd8628 | ||
|  | 119c1b43be | ||
|  | 1277ce38de | ||
|  | 6761b91400 | ||
|  | 2a6244a5c2 | ||
|  | 777bddd3d8 | ||
|  | e2b13791bb | ||
|  | f44c21ce59 | ||
|  | ade977e416 | ||
|  | f09a131bd6 | ||
|  | 4815587de1 | ||
|  | e0ebfb9b53 | ||
|  | 90836afd91 | ||
|  | 4e1b0a25bb | ||
|  | 89c3236bf5 | ||
|  | 7658bc2025 | ||
|  | 7cf60c7c35 | ||
|  | ccde725d3b | ||
|  | e3b45cac0a | ||
|  | 8f8a500dcd | ||
|  | f9749cd82c | ||
|  | 051052fdd2 | ||
|  | 940304b4c2 | ||
|  | b4d2fae27f | ||
|  | 11e194292c | ||
|  | 5ba6f6f53e | ||
|  | f58a16905f | ||
|  | 33e82b336b | ||
|  | 0ced712974 | ||
|  | db8e35cc13 | ||
|  | b6db5aa2d3 | ||
|  | 396dc5c9b0 | ||
|  | 67e424a32a | ||
|  | d8cbec41d2 | ||
|  | 374f6b8d52 | ||
|  | 20ec4cbd14 | ||
|  | 1c80835f49 | ||
|  | 5e0af26c27 | ||
|  | b42674ac06 | ||
|  | 3394543705 | ||
|  | 75c51aa61b | ||
|  | 6041722250 | ||
|  | 60d038b367 | ||
|  | b2c4bf96af | ||
|  | f007f9a86d | ||
|  | b1c1634950 | ||
|  | 5157ccf7c0 | ||
|  | c4a782301d | ||
|  | 17fe94fa46 | ||
|  | 75d9347d23 | ||
|  | ef784124f3 | ||
|  | bd1b631914 | ||
|  | edfc8cfdc4 | ||
|  | fbe34015d4 | ||
|  | 391fa008d0 | ||
|  | 7df8381b8f | ||
|  | c0234ae328 | ||
|  | 5c64a85d7c | ||
|  | 7aa8f115ce | ||
|  | cf2c8d6c67 | ||
|  | 37edebcad9 | ||
|  | 4d4f661548 | ||
|  | 46e4cb4f50 | ||
|  | 34e622cf0c | ||
|  | 7ccb99aa2c | ||
|  | 9e3847e56f | ||
|  | 90ced351f4 | ||
|  | 04295ea8c5 | ||
|  | 2452d3c24b | ||
|  | 302428f1d1 | ||
|  | cf603aa80e | ||
|  | 1f9f949a8c | ||
|  | 0bde1e97dc | ||
|  | 42aca2e40f | ||
|  | e1e44d35bb | ||
|  | a790b2e529 | ||
|  | 357946388c | ||
|  | b774583f28 | ||
|  | 6436daca08 | ||
|  | f153c7bb80 | ||
|  | 1f8a618dcc | ||
|  | 2d853e5a2f | ||
|  | 361dc79ede | ||
|  | 19173321ea | ||
|  | 87b724ec72 | ||
|  | 67db0f950b | ||
|  | f85bbf12ca | ||
|  | 37e4f1e8d5 | ||
|  | 44d8bfd763 | ||
|  | 0cdbad6194 | ||
|  | 4799a8a68e | ||
|  | 8caec81d1e | ||
|  | 83d5bf45e5 | ||
|  | 037eb8a163 | ||
|  | 3e5025b46e | ||
|  | 35a5dc6219 | ||
|  | ace3ca0ad9 | ||
|  | a8a498ddea | ||
|  | d16663f0a9 | ||
|  | 623470209f | ||
|  | 553de5a873 | ||
|  | ccf8773b18 | ||
|  | cad25bf85d | ||
|  | 9ce748452d | ||
|  | 9263d74b75 | ||
|  | 9601bddc84 | ||
|  | e281e19052 | ||
|  | 0238b78f45 | ||
|  | 0ccee4326d | ||
|  | e9ab54f657 | ||
|  | 0afb1a2d04 | ||
|  | 2d2b2964a5 | ||
|  | 10c4f7631b | ||
|  | d921cffdaa | ||
|  | 5369e21780 | ||
|  | d34fb4494e | ||
|  | 1bd493ea37 | ||
|  | 391c3fe4c9 | ||
|  | 3a7da9f13b | ||
|  | 3780767ccc | ||
|  | 411279b3eb | ||
|  | be3069e0e5 | ||
|  | 22cf870555 | ||
|  | d6479c1390 | ||
|  | a4e82c79cc | ||
|  | bcc89adb5f | ||
|  | a41c9e339a | ||
|  | feeca77436 | ||
|  | fcce12ba40 | ||
|  | f4b06fb834 | ||
|  | e7fd803d19 | ||
|  | 3b96747871 | ||
|  | 33088df07d | ||
|  | a215714b6b | ||
|  | 6a9904fd43 | ||
|  | cc297ccfcd | ||
|  | c7c88dec04 | ||
|  | e481a5926a | ||
|  | 0464245218 | ||
|  | 0001f31c06 | ||
|  | 391d31759a | ||
|  | ed2f2435d2 | ||
|  | 6e6b2ccfa0 | ||
|  | be9a73560d | ||
|  | e82b5a4ecf | ||
|  | a27d8ac828 | ||
|  | 6267258189 | ||
|  | e7527f532e | ||
|  | 8b6e74d505 | ||
|  | e6106c0c4e | ||
|  | f52bafc014 | ||
|  | 9e0630ea79 | ||
|  | 968bfb92d0 | ||
|  | 284c9fcee2 | ||
|  | 5b0b939531 | ||
|  | 2efac0c96b | ||
|  | dc52fd1dcf | ||
|  | 19240a9caf | ||
|  | 4eef28f93d | ||
|  | c6e5c4e3b5 | ||
|  | 007f567c7a | ||
|  | ffe178c64c | ||
|  | c3835cefb1 | ||
|  | 2c382f3d3f | ||
|  | b592aa6a02 | ||
|  | 57e82b62a1 | ||
|  | 13f3aca838 | ||
|  | 94b17eaff3 | ||
|  | a3038da3d7 | ||
|  | 6026b7800a | ||
|  | 36c5b71656 | ||
|  | a320bea68a | ||
|  | a87fe8b44d | ||
|  | 0a2b4dedc7 | ||
|  | f7ed3eefc8 | ||
|  | 8bb3a3f8a6 | ||
|  | 89d08ca359 | ||
|  | b80aec37e0 | ||
|  | e34fd855a9 | ||
|  | fc12ea18b8 | ||
|  | f87df53791 | ||
|  | d6746362a4 | ||
|  | 2850bd0b46 | ||
|  | b762eff4eb | ||
|  | 4b3b96447f | ||
|  | 13bcfbe3c5 | ||
|  | 8525fb89f8 | ||
|  | ed2d1c4932 | ||
|  | 5091f8457e | ||
|  | 84b69fc58c | ||
|  | a2cac003a4 | ||
|  | 7c16a90221 | ||
|  | 97cdd53861 | ||
|  | b7ee00fb22 | ||
|  | ef2ee20820 | ||
|  | 4866e5050a | ||
|  | 8e36c456e1 | ||
|  | 4b8bcd265b | ||
|  | 0db681eeda | ||
|  | 8823f69256 | ||
|  | f3e9dfe734 | ||
|  | a7b31ab1f9 | ||
|  | 644ab27186 | ||
|  | e90ecd2085 | ||
|  | bc38184ebf | ||
|  | d9de27e6f2 | ||
|  | 6930bf0200 | ||
|  | 199833bdd4 | ||
|  | 0dcd2e6e93 | ||
|  | 0dd43d5c9a | ||
|  | e879bd0fc5 | ||
|  | 8bf7daff65 | ||
|  | ae0f01d326 | ||
|  | af8d0a3965 | ||
|  | 1b170c74c0 | ||
|  | f6b9ff50c3 | ||
|  | 9ef75ebcde | ||
|  | f76a618768 | ||
|  | 098d7baa4d | ||
|  | 59a57d3d28 | ||
|  | cce95e09de | ||
|  | ec48ebcd79 | ||
|  | 908f80a15d | ||
|  | 02eab89d82 | ||
|  | c588786a06 | ||
|  | b4f3105035 | ||
|  | d018f11877 | ||
|  | d0000c6131 | ||
|  | c05ffefd7d | ||
|  | 530fc67a05 | ||
|  | c79ec11b07 | ||
|  | 668ef26056 | ||
|  | 75ec7723ef | ||
|  | 73e609fa29 | ||
|  | 8cb06bf451 | ||
|  | 1be8a059f4 | ||
|  | 7f41c7ab0e | ||
|  | 3860c5d8ec | ||
|  | a061a7cc4d | ||
|  | 844501d6cd | ||
|  | 020bd00b8f | ||
|  | 0706b0d287 | ||
|  | ce56cd2b16 | ||
|  | b7a0a7eea4 | ||
|  | 824d14e793 | ||
|  | 83e0ed2b5d | ||
|  | c8b70b51c3 | ||
|  | c0fedaa3a4 | ||
|  | e74dcff010 | ||
|  | 3b5b45b463 | ||
|  | fead482b0d | ||
|  | 29bd8203b5 | ||
|  | 08b79e45cf | ||
|  | 3a05a0bcaa | ||
|  | d0aafaee60 | ||
|  | 332b874493 | ||
|  | 6c995ed738 | ||
|  | fb09d77cdc | ||
|  | 9c952785e6 | ||
|  | 2f51c1bf47 | ||
|  | 276a110e90 | ||
|  | b761674b2c | ||
|  | 0b20bf0145 | ||
|  | 1397cbeac2 | ||
|  | 06e122f303 | ||
|  | f062acfd7c | ||
|  | 97ca414fc0 | ||
|  | a9af5bcec4 | ||
|  | 7e30bf4197 | ||
|  | c724ea9f69 | ||
|  | e6cc4a1180 | ||
|  | 3cce097b9d | ||
|  | 53f9d6869d | ||
|  | 61beee0f49 | ||
|  | 1f3d400ad6 | ||
|  | f2ff2187d9 | ||
|  | 28ddc0055f | ||
|  | 90b5b6bd8b | ||
|  | 53466797a5 | ||
|  | f5235938b7 | ||
|  | 054860b38d | ||
|  | b60d02b8f4 | ||
|  | 0d69797851 | ||
|  | bfffff0750 | ||
|  | b7bcd8da7d | ||
|  | d3862c97ba | ||
|  | c069c39ce1 | ||
|  | e994d501b0 | ||
|  | caf163f98c | ||
|  | 1c408db907 | ||
|  | 8d44e07c32 | ||
|  | d99fae4340 | ||
|  | d49caa29ce | ||
|  | 8bebe11b4e | ||
|  | 236a456cae | ||
|  | 7bc745fa8e | ||
|  | 056fc8fbaf | ||
|  | b6aa507b41 | ||
|  | 4b1a5a5e14 | ||
|  | a364206159 | ||
|  | b5feb5f733 | ||
|  | 991125034e | ||
|  | a0fe1a85f1 | ||
|  | 3a2e68c334 | ||
|  | b6418cd912 | ||
|  | e652038018 | ||
|  | b2e2af51ed | ||
|  | 9502444bbc | ||
|  | a0fe803c35 | ||
|  | ea2ca37abe | ||
|  | 0601ffbb34 | ||
|  | 09a7fcaba4 | ||
|  | ce15784851 | ||
|  | b861b2dffb | ||
|  | e50fd786da | ||
|  | 5e82de667e | ||
|  | d7ddcda9da | ||
|  | 6d031130b9 | ||
|  | a61b0685f0 | ||
|  | abfeafc823 | ||
|  | 3a51be3430 | ||
|  | 3b914d4a7f | ||
|  | ede4802ceb | ||
|  | fe79119809 | ||
|  | 319d96f94e | ||
|  | 6f07dc7852 | ||
|  | 16bcba6e2e | ||
|  | 1002acb021 | ||
|  | b771544c5d | ||
|  | 8c7f09c454 | ||
|  | 618cffefb1 | ||
|  | 8fd37e857e | ||
|  | 8218bfd24b | ||
|  | cbb2dbffb9 | ||
|  | 528a838643 | ||
|  | cbed6418e7 | ||
|  | 4882cc92a8 | ||
|  | 28fb11068e | ||
|  | 394ced9fb9 | ||
|  | 90465149e6 | ||
|  | c6d868d981 | ||
|  | bada4fd140 | ||
|  | 60f96d15bd | ||
|  | 0328007345 | ||
|  | 3ad0e92a0f | ||
|  | 3934120541 | ||
|  | 24ca126f5a | ||
|  | 651ca71126 | ||
|  | e7cb33d8e2 | ||
|  | c63d238316 | ||
|  | dcdc48d917 | ||
|  | f4c1671079 | ||
|  | 7aa2fbee1c | ||
|  | f1939fdc2b | ||
|  | c9356d0ff5 | ||
|  | 6b5d938a40 | ||
|  | d82da160f3 | ||
|  | 54c8aac20d | ||
|  | 314b6fc2f8 | ||
|  | 974df031a0 | ||
|  | 36d0292c6b | ||
|  | 7c16952c92 | ||
|  | 557807e3ba | ||
|  | c1d5b2df29 | ||
|  | 05be5c1199 | ||
|  | f19a65148a | ||
|  | a55fc4fff9 | ||
|  | 35a7a70b93 | ||
|  | 3e0574e563 | ||
|  | 69e557e70d | ||
|  | 1b846be5fc | ||
|  | 707eb58068 | ||
|  | 8630f3be96 | ||
|  | c90aeba286 | ||
|  | 5055cfc6cb | ||
|  | c222c4eb29 | ||
|  | 6c01955561 | ||
|  | 305e0f1772 | ||
|  | 52a94e3256 | ||
|  | a418fb18b6 | ||
|  | 9cd579d3db | ||
|  | e1a6ba7377 | ||
|  | 04aabe0921 | ||
|  | 8dd4d71d75 | ||
|  | 49dd63af1e | ||
|  | 18c6f0ccc3 | ||
|  | 55c50a4b5b | ||
|  | 12b3267d5c | ||
|  | d6d564c027 | ||
|  | 1fbd5f7922 | ||
|  | f0e13fa492 | ||
|  | c8d5ac9248 | ||
|  | aa4f77d4de | ||
|  | f3ef112297 | ||
|  | bbb71083ef | ||
|  | e2134d76ec | ||
|  | 651372cd64 | ||
|  | 581fe17b58 | ||
|  | af8608f302 | ||
|  | 290a19b6c6 | ||
|  | 73eaa93be8 | ||
|  | 7ab17383a6 | ||
|  | b103c5b13f | ||
|  | b7d8a83017 | ||
|  | e7bf4f455d | ||
|  | a7f212c4f2 | ||
|  | eb991c6026 | ||
|  | a78af8f248 | ||
|  | f34bdf0f58 | ||
|  | ba272253a5 | ||
|  | 9f488b7b77 | ||
|  | 3fb7df18a0 | ||
|  | 00401080e0 | ||
|  | b265dc3bfb | ||
|  | 63cabbe960 | ||
|  | f6c1a7e6db | ||
|  | a3dcacade9 | ||
|  | 17e65e422c | ||
|  | f53e2ffa47 | ||
|  | a1e4047695 | ||
|  | 47ce884bbe | ||
|  | 1b17c2613d | ||
|  | dedc8d89c7 | ||
|  | d00fce86d2 | ||
|  | abab2d1cde | ||
|  | 33b715eb4e | ||
|  | f6effbb6bb | ||
|  | dff9ec0704 | ||
|  | bfaf4c58e4 | ||
|  | ab7d24b637 | ||
|  | c256dae736 | ||
|  | 5a55ef64c4 | ||
|  | 045026431b | ||
|  | 4dff91a0e5 | ||
|  | 7105872a37 | ||
|  | 179bd8e018 | ||
|  | c82293342f | ||
|  | 81bf79e9d3 | ||
|  | 8d6dffb3ff | ||
|  | 2f6a8f793b | ||
|  | 9bcd0bbfac | ||
|  | cd359de7eb | ||
|  | 000f9ed459 | ||
|  | c5b2c0b4ec | ||
|  | b7e9af100a | ||
|  | 0d6409098f | ||
|  | e07238ded8 | ||
|  | 27903f278d | ||
|  | ddf966d08c | ||
|  | 65dca36ae1 | ||
|  | 289dae0780 | ||
|  | 71f802ef35 | ||
|  | 0135f25b9d | ||
|  | de3ebf664f | ||
|  | 850d879726 | ||
|  | 5397e6c723 | ||
|  | 889f6fc5fc | ||
|  | 41c2ed7c67 | ||
|  | cdf47d4719 | ||
|  | 210368bea0 | ||
|  | 4f48751d0b | ||
|  | b6d3e82304 | ||
|  | 3bb3528aa5 | ||
|  | 4f892835b8 | ||
|  | ac49221639 | ||
|  | 75ed5db3e4 | ||
|  | 59c8e4e6a2 | ||
|  | 52b322b756 | ||
|  | dc876d9c96 | ||
|  | 5b028428c4 | ||
|  | f67a0469fc | ||
|  | 494cd0efff | ||
|  | fc8e38e862 | ||
|  | f09fb5a209 | ||
|  | b00c1c1575 | ||
|  | 7e5dd62a92 | ||
|  | 35718f6215 | ||
|  | a6d3891a95 | ||
|  | 9591c4eb58 | ||
|  | 8aaf720b0b | ||
|  | 63a35c97bc | ||
|  | 8eddaa3fac | ||
|  | 1b3f37eb78 | ||
|  | 1f8fbfaa24 | ||
|  | ea92ccb4c1 | ||
|  | d25a77b633 | ||
|  | 51bb776e56 | ||
|  | 47b1b6daba | ||
|  | adeb654248 | ||
|  | c4d7335fdd | ||
|  | ca7f42c409 | ||
|  | ca02cd72ae | ||
|  | 1ba542eb50 | ||
|  | 53cd967541 | ||
|  | 49749a0bc7 | ||
|  | 446f924380 | ||
|  | 5b231170cd | ||
|  | 7375357b11 | ||
|  | 347d799d85 | ||
|  | 0d17f02191 | ||
|  | ce5bc80347 | ||
|  | 0a4479fe9e | ||
|  | de8e96cd75 | ||
|  | e2a62f88a6 | ||
|  | 8926d2a73c | ||
|  | 114833cf8e | ||
|  | 32227436e0 | ||
|  | 28ff5636af | ||
|  | 656896d16f | ||
|  | 19bf8afece | ||
|  | 841b6c4ddf | ||
|  | 4c171df848 | ||
|  | 1f79d88840 | ||
|  | 6ee7e9d731 | ||
|  | 4856223838 | ||
|  | 74ea2a847d | ||
|  | 9813dde3d9 | ||
|  | fea7b62b9c | ||
|  | 37e03bf2bb | ||
|  | 5656de79a2 | ||
|  | 70c6048cc1 | ||
|  | 87595fd704 | ||
|  | dc030a42bb | ||
|  | 89283ed179 | ||
|  | 64e8a05a9f | ||
|  | 676320586a | ||
|  | 734fa51806 | ||
|  | f056ecc8d8 | ||
|  | 1a722c1517 | ||
|  | 44607ba6a4 | ||
|  | 01d66212da | ||
|  | 925e10b19b | ||
|  | 1b4c75a76e | ||
|  | 3400e36ac4 | ||
|  | 78e2ae4f36 | ||
|  | 957944f6a5 | ||
|  | 9eab500e2c | ||
|  | 573f4675a1 | ||
|  | e6bde3e1f4 | ||
|  | 5869174021 | ||
|  | 449761b6ca | ||
|  | 39d5ce19e2 | ||
|  | 3b156bc5c9 | ||
|  | a4f5124b61 | ||
|  | 47a34c2f54 | ||
|  | 8a7446fb40 | ||
|  | 705e7d1cf1 | ||
|  | 44a90b4e12 | ||
|  | 54e5a65cf0 | ||
|  | 06a2c380bd | ||
|  | 33ac1fed2a | ||
|  | cc65a7cd11 | ||
|  | d600a54034 | ||
|  | ba06225b01 | ||
|  | ce60ab8e00 | ||
|  | 14f6fd19ef | ||
|  | 1d8351f921 | ||
|  | 6a55b052f5 | ||
|  | 2a36b83dea | ||
|  | 14acc4feb9 | ||
|  | 0657ca2969 | ||
|  | e90c3a78d1 | ||
|  | 63c9bc5c1c | ||
|  | a6bbc81962 | ||
|  | b800fb5846 | ||
|  | 172a629da3 | ||
|  | 6d1f7b36a7 | ||
|  | 673ee4aeed | ||
|  | 25b787f6f2 | ||
|  | 6b74ef77e6 | ||
|  | 278201e87c | ||
|  | 703cdfe174 | ||
|  | 02988989ad | ||
|  | 25c17d3704 | ||
|  | 9b4d832d17 | ||
|  | 52ab19dec6 | ||
|  | 9973fe4326 | ||
|  | 2479f2d65d | ||
|  | 9056cb7026 | ||
|  | cd9d9b31ef | ||
|  | ff841c28e3 | ||
|  | ca1379d9f8 | ||
|  | 5127f94423 | ||
|  | f5910ab950 | ||
|  | 22efaccd4a | ||
|  | c8466a2e7a | ||
|  | 209a9fa8c3 | ||
|  | bc1af12655 | ||
|  | e7e4cb7579 | ||
|  | 1b39db664c | ||
|  | 7397b9fa87 | ||
|  | 5bed5fb8fd | ||
|  | fd795b4361 | ||
|  | b2c0915a71 | ||
|  | 095083bcfb | ||
|  | 4ba72f7eeb | ||
|  | 6cb39795a9 | ||
|  | 00ba16f536 | ||
|  | 988a839623 | ||
|  | 8fa61e628c | ||
|  | 8f3620e07b | ||
|  | 190f70f332 | ||
|  | 6730683919 | ||
|  | 51b12567e8 | ||
|  | d01cdeded8 | ||
|  | d22a03f1a5 | ||
|  | ab0aeec434 | ||
|  | 47ff51ce4e | ||
|  | 1d62ef357d | ||
|  | 59ef66f46d | ||
|  | 77479215a6 | ||
|  | 11da1f72b1 | ||
|  | 6375b9d14d | ||
|  | 42bc12f56d | ||
|  | 8515112811 | ||
|  | bedb87674b | ||
|  | 029c038a49 | ||
|  | 95e905a5ae | ||
|  | 8a8ec32f2c | ||
|  | cd958398af | ||
|  | 2eedafd506 | ||
|  | 9a88c0d579 | ||
|  | cb6fc466d1 | ||
|  | a6154cbb43 | ||
|  | 9c20967d24 | ||
|  | 79bbadad2f | ||
|  | c3b44cee94 | ||
|  | 1968496ce1 | ||
|  | e1feb46de9 | ||
|  | f51a082049 | ||
|  | 0f1927b4b1 | ||
|  | 9baccc0784 | ||
|  | 95c9cc7f99 | ||
|  | 0ed8e04233 | ||
|  | b46c328811 | ||
|  | c7d88ed95b | ||
|  | 94da1a30dc | ||
|  | 219a5db60c | ||
|  | a5cfedcae9 | ||
|  | 9b8a632f37 | ||
|  | be0426d9a2 | ||
|  | 8fac0a02e5 | ||
|  | fa696a2901 | ||
|  | f5615b1c54 | ||
|  | 9850c294d1 | ||
|  | 8929f32068 | ||
|  | 3019bb5c97 | ||
|  | 95044d27ce | ||
|  | 16ac92b2ef | ||
|  | f095964f7b | ||
|  | cfba793fcf | ||
|  | c50a11e58a | ||
|  | a7282a5794 | ||
|  | efaf313422 | ||
|  | d9f5753f58 | ||
|  | 179318f1d8 | ||
|  | dd33d24346 | ||
|  | fa684eabab | ||
|  | 4b5bd6eed7 | ||
|  | 63f5946527 | ||
|  | 8dac3ebf96 | ||
|  | 09e783fbf6 | ||
|  | b18b686545 | ||
|  | e99ea41117 | ||
|  | 172a37ec8c | ||
|  | da6b341b63 | ||
|  | 16d93b1775 | ||
|  | e15cf063c6 | ||
|  | 5ac9e3e47a | ||
|  | 743a61bf49 | ||
|  | c790ea07dd | ||
|  | b4f980b349 | ||
|  | 673f23b6a0 | ||
|  | 8c325f3a8a | ||
|  | f71516f36f | ||
|  | 1752386a6c | ||
|  | 112675c782 | ||
|  | 3b6ba7641d | ||
|  | 477a35a685 | ||
|  | 2a0a39a95a | ||
|  | dfc77db51d | ||
|  | c9596fd8c4 | ||
|  | 78373f3746 | ||
|  | ebc3db8aed | ||
|  | 756601c1ce | ||
|  | 8bb5077e76 | ||
|  | 5b85f01427 | ||
|  | a7d594e566 | ||
|  | 481e6671c2 | ||
|   | b890e02a6a | ||
|   | 4772840b4c | ||
|  | cd7adc997b | ||
|  | 9fdc901b7a | ||
|   | 76ec3473d6 | ||
|  | b29ae67501 | ||
|  | 24f1fb566d | ||
|  | a253294890 | ||
|   | 0b53de1bb6 | ||
|   | 746c68c9f6 | ||
|  | ec008b4a16 | ||
|  | 1d65e34fe5 | ||
|  | 8ae78703ca | ||
|  | bd4b9a9fd3 | ||
|  | f09677d376 | ||
|  | f669e3f6c4 | ||
|  | 961c5ea962 | ||
|  | a1c61a1146 | ||
|  | 797dff4752 | ||
|  | 711ad638b8 | ||
|  | 4956c3328c | ||
|  | 96a82381d1 | ||
|  | 68190c8c76 | ||
|  | dde47bc1fc | ||
|  | 744deaed8d | ||
|  | a62910c8b6 | ||
|  | e259a09b89 | ||
|  | 6472d8438a | ||
|  | dc8a402a4a | ||
|  | f72374488e | ||
|  | 3c6d9a4b8e | ||
|  | 3073388403 | ||
|  | 67f856c997 | ||
|  | 742fd0b444 | ||
|  | e1d69c0eae | ||
|  | 49d4190615 | ||
|  | 64d39765ca | ||
|  | aca8f64415 | ||
|  | 855b600268 | ||
|  | 476d613e20 | ||
|  | fb8a4d0a41 | ||
|  | 130f3f6d41 | ||
|  | ed997af043 | ||
|  | 3c47f6917a | ||
|  | e32a486493 | ||
|  | 208da935a1 | ||
|  | 1dda9a875e | ||
|  | b26175b7c6 | ||
|  | ffc6befb38 | ||
|  | 9df7c96d08 | ||
|  | 32fa66f0a2 | ||
|  | 7153ed022c | ||
|  | 50e4e71abb | ||
|  | d6e65a3d63 | ||
|  | 7a77b12834 | ||
|  | fe387931a4 | ||
|  | dc83e32173 | ||
|  | f5b29bad33 | ||
|  | f599aa4852 | ||
|  | 6f05de2fcc | ||
|  | 02016c77f1 | ||
|  | 93eede7c6b | ||
|  | 944e396823 | ||
|  | 8a654834ac | ||
|  | bb76fa80cd | ||
|  | 53f44a4029 | ||
|  | 182dcc7e5f | ||
|  | 2d272e0207 | ||
|  | 9384a50879 | ||
|  | 00d4f35f2c | ||
|  | 483557163e | ||
|  | 2679252b04 | ||
|  | 5a95c827b4 | ||
|  | f938d8c878 | ||
|  | bfc0122c1b | ||
|  | 79691541ae | ||
|  | 05d0542875 | ||
|  | 1d22b39a1d | ||
|  | 549b68cf71 | ||
|  | 55f87de2e0 | ||
|  | b8424e20aa | ||
|  | bbe3a30e77 | ||
|  | cdc5348a06 | ||
|  | e64a3978e6 | ||
|  | f2feb12708 | ||
|  | 5319c5f832 | ||
|  | 7531fe14fe | ||
|  | 0086c6373b | ||
|  | 10dc19652e | ||
|  | 2f2c4964c5 | ||
|  | cb4203b6db | ||
|  | bb5b62466e | ||
|  | 6407b5452b | ||
|  | a742fecf9c | ||
|  | 60415b9222 | ||
|  | ffb39ef074 | ||
|  | d73f5ed2b5 | ||
|  | 7af973b60d | ||
|  | 49eddc9da5 | ||
|  | 4b1de108d1 | ||
|  | e46c735efa | ||
|  | 56da7deb4c | ||
|  | 5f4649ee2b | ||
|  | 7cc2c4f621 | ||
|  | cc449f9304 | ||
|  | 28425efbe7 | ||
|  | 8c3a22aa5c | ||
|  | f3e5933599 | ||
|  | 39d8750ef9 | ||
|  | 52b0c244a8 | ||
|  | ee95a00ce2 | ||
|  | 11566ec923 | ||
|  | a78ff08202 | ||
|  | ceb3969c8b | ||
|  | 6f048ef40e | ||
|  | aac4fe37e8 | ||
|  | ebfa941a4f | ||
|  | 47c70eea9e | ||
|  | fe7c40cb7c | ||
|  | 8973626a4b | ||
|  | ace5d999e2 | ||
|  | 52829a244b | ||
|  | 71c247fe56 | ||
|  | b34066f579 | ||
|  | b286c15c51 | ||
|  | aff4f6c859 | ||
|  | 1f8f73fa30 | ||
|  | 620d6624a9 | ||
|  | 287f42ae64 | ||
|  | d976c97d13 | ||
|  | 6d549b0754 | ||
|  | 02dd64558f | ||
|  | ea5e2f5580 | ||
|  | b65c7a75fe | ||
|  | 39f5dce51c | ||
|  | f77ec1faf6 | ||
|  | cd3a1e0159 | ||
|  | 3f20fadb81 | ||
|  | 1c6428dd3b | ||
|  | aca620241a | ||
|  | 808b36e088 | ||
|  | 1613375cc0 | ||
|  | 787ef957d2 | ||
|  | b915d0aed5 | ||
|  | 16dc5b5327 | ||
|  | d25e98d9c4 | ||
|  | 227cdc1ec8 | ||
|  | c2d0c63db0 | ||
|  | f5c9807a48 | ||
|  | 7e9b1d5e16 | ||
|  | 5070c4eea9 | ||
|  | 33d9c51b6f | ||
|  | d0f9d478c2 | ||
|  | f296ec46c8 | ||
|  | 64d19e480b | ||
|  | 2c585a9328 | ||
|  | 45b0d9e19b | ||
|  | 026a4b896c | ||
|  | 78237fcd11 | ||
|  | 73cc3dcb92 | ||
|  | 4cff03e7fe | ||
|  | 8e35f131d5 | ||
|  | aafb9d7e4f | ||
|  | 652f30bdbd | ||
|  | f4ba7b2a0c | ||
|  | 592490d709 | ||
|  | 5ac676d201 | ||
|  | abfe0a925a | ||
|  | fa11f4f45b | ||
|  | 1e8dbfe4b7 | ||
|  | f82f2bd287 | ||
|  | 76742c3869 | ||
|  | 9407e7e418 | ||
|  | 120552b36e | ||
|  | 9fb58c7ae3 | ||
|  | b917da765c | ||
|  | cc59510cd0 | ||
|  | 86aab7023c | ||
|  | e3b0a41ba9 | ||
|  | 802c55dfc8 | ||
|  | 280f7814aa | ||
|  | 3174681bd8 | ||
|  | 853f80b928 | ||
|  | 8bdad377d7 | ||
|  | 9e1c2a5bd1 | ||
|  | b1777b6011 | ||
|  | 904be3005f | ||
|  | 95eaf4c887 | ||
|  | e3923b7b22 | ||
|  | a43993e3ec | ||
|  | bc6b3fb5f4 | ||
|  | df47f5d47b | ||
|  | 319e64bacc | ||
|  | ecf044bed1 | ||
|  | 76e1de38e8 | ||
|  | 1648a74ed7 | ||
|  | c63a7884cb | ||
|  | cffbd41f26 | ||
|  | c443187d0b | ||
|  | 8c305d8390 | ||
|  | 0345c5c03b | ||
|  | cc6ac4c9d9 | ||
|  | 2ceba45782 | ||
|  | ed423ed041 | ||
|  | f58a52eaa4 | ||
|  | 688404011b | ||
|  | 8881e0fb75 | ||
|  | 61de7e144e | ||
|  | 815934ff5c | ||
|  | c3ff9e49e8 | ||
|  | d52875aa8f | ||
|  | 9027cd290c | ||
|  | 58a7203ede | ||
|  | 5a84016a91 | ||
|  | bb0269f484 | ||
|  | 1adc9349fc | ||
|  | 06215c83f2 | ||
|  | 8a828137ee | ||
|  | de4b1c8f09 | ||
|  | 265d40f04a | ||
|  | b64e52c0c0 | ||
|  | ac02e5c0a6 | ||
|  | 427a415fbf | ||
|  | 9a3414aaa7 | ||
|  | 7f5486dade | ||
|  | c8897ecf9b | ||
|  | 2c8b94d469 | ||
|  | 36c1cfae51 | ||
|  | 41ad78750e | ||
|  | 3eaa4d5b38 | ||
|  | 35176f9550 | ||
|  | eb2c7268ea | ||
|  | 80311355ae | ||
|  | 39d1590ace | ||
|  | 0b36b650a4 | ||
|  | 39575e8542 | ||
|  | 326ef498b0 | ||
|  | 5148bad82e | ||
|  | 518f02472f | ||
|  | ee5a132eb2 | ||
|  | 654dc5bff3 | ||
|  | 27aeb4f05f | ||
|  | 13dcf7f72a | ||
|  | 65c26f17df | ||
|  | 3bedba71d5 | ||
|  | 1ba64d9745 | ||
|  | 84bf570243 | ||
|  | 28d50bccf9 | ||
|  | 66711c2e9d | ||
|  | 92d8c37d7d | ||
|  | 5824f75669 | ||
|  | deb8adafc9 | ||
|  | d2649b237c | ||
|  | 255233fe38 | ||
|  | 6532c258f6 | ||
|  | 4ac3984b7c | ||
|  | 83e2b10904 | ||
|  | 26c1793d85 | ||
|  | 23a9eea26b | ||
|  | af9b536dd9 | ||
|  | e4874f86f9 | ||
|  | e300a957e4 | ||
|  | 1c38afcd25 | ||
|  | a06faa7685 | ||
|  | 019ab2b21d | ||
|  | f6ba5f5d51 | ||
|  | c4cbb64643 | ||
|  | 8260cec713 | ||
|  | 428af3c0e8 | ||
|  | 68544715bf | ||
|  | d2ea5633fb | ||
|  | f4520e2752 | ||
|  | 475802afbc | ||
|  | 3aa9b5f0b6 | ||
|  | 6c5dbf7bd0 | ||
|  | 3b3dc5032b | ||
|  | a170668d9d | ||
|  | f8dac39076 | ||
|  | fe4ae61552 | ||
|  | 45efe7cd56 | ||
|  | 78cac7f0e6 | ||
|  | a1a1b8e94a | ||
|  | 641a658059 | ||
|  | 08f3d653cc | ||
|  | f2bbafe6c2 | ||
|  | cb80280eaf | ||
|  | f22f954ae3 | ||
|  | 2556855bd7 | ||
|  | 365662a2af | ||
|  | 3e0ff7f43f | ||
|  | 8c3753326f | ||
|  | dbcf6de2d5 | ||
|  | a5308995b7 | ||
|  | 270ac88b51 | ||
|  | a9c7142d7b | ||
|  | 7a40c3526f | ||
|  | 3253d9d3fb | 
							
								
								
									
										33
									
								
								.github/workflows/pr-testing.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								.github/workflows/pr-testing.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| name: PR testing | ||||
|  | ||||
| on: | ||||
|   pull_request: | ||||
|     branches: [ master ] | ||||
|  | ||||
| jobs: | ||||
|   mavenTesting: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|     - uses: actions/checkout@v2 | ||||
|     - name: Cache local Maven repository | ||||
|       uses: actions/cache@v2 | ||||
|       with: | ||||
|         path: ~/.m2/repository | ||||
|         key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} | ||||
|         restore-keys: | | ||||
|           ${{ runner.os }}-maven- | ||||
|     - name: Set up the Java JDK | ||||
|       uses: actions/setup-java@v2 | ||||
|       with: | ||||
|         java-version: '11' | ||||
|         distribution: 'adopt' | ||||
|  | ||||
|     - name: Run all tests | ||||
|       run: | | ||||
|         mvn -B clean test -DskipTests=false --file pom.xml | ||||
|         if [ -f "target/site/jacoco/index.html" ]; then echo "Total coverage: $(cat target/site/jacoco/index.html | grep -o 'Total[^%]*%' | grep -o '[0-9]*%')"; fi | ||||
|  | ||||
|     - name: Log coverage percentage | ||||
|       run: | | ||||
|         if [ ! -f "target/site/jacoco/index.html" ]; then echo "No coverage information available"; fi | ||||
|         if [ -f "target/site/jacoco/index.html" ]; then echo "Total coverage: $(cat target/site/jacoco/index.html | grep -o 'Total[^%]*%' | grep -o '[0-9]*%')"; fi | ||||
							
								
								
									
										18
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										18
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,6 +1,8 @@ | ||||
| /db* | ||||
| /lists/ | ||||
| /bin/ | ||||
| /target/ | ||||
| /qortal-backup/ | ||||
| /log.txt.* | ||||
| /arbitrary* | ||||
| /Qortal-BTC* | ||||
| @@ -14,8 +16,18 @@ | ||||
| /settings.json | ||||
| /testnet* | ||||
| /settings*.json | ||||
| /testchain.json | ||||
| /run-testnet.sh | ||||
| /testchain*.json | ||||
| /run-testnet*.sh | ||||
| /.idea | ||||
| /qortal.iml | ||||
| *.DS_Store | ||||
| .DS_Store | ||||
| /src/main/resources/resources | ||||
| /*.jar | ||||
| /run.pid | ||||
| /run.log | ||||
| /WindowsInstaller/Install Files/qortal.jar | ||||
| /*.7z | ||||
| /tmp | ||||
| /data* | ||||
| /src/test/resources/arbitrary/*/.qortal/cache | ||||
| apikey.txt | ||||
|   | ||||
							
								
								
									
										26
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| FROM maven:3-openjdk-11 as builder | ||||
|  | ||||
| WORKDIR /work | ||||
| COPY ./ /work/ | ||||
| RUN mvn clean package | ||||
|  | ||||
| ### | ||||
| FROM openjdk:11 | ||||
|  | ||||
| RUN useradd -r -u 1000 -g users qortal && \ | ||||
|     mkdir /usr/local/qortal /qortal && \ | ||||
|     chown 1000:100 /qortal | ||||
|  | ||||
| COPY --from=builder /work/log4j2.properties /usr/local/qortal/ | ||||
| COPY --from=builder /work/target/qortal*.jar /usr/local/qortal/qortal.jar | ||||
|  | ||||
| USER 1000:100 | ||||
|  | ||||
| EXPOSE 12391 12392 | ||||
| HEALTHCHECK --start-period=5m CMD curl -sf http://127.0.0.1:12391/admin/info || exit 1 | ||||
|  | ||||
| WORKDIR /qortal | ||||
| VOLUME /qortal | ||||
|  | ||||
| ENTRYPOINT ["java"] | ||||
| CMD ["-Djava.net.preferIPv4Stack=false", "-jar", "/usr/local/qortal/qortal.jar"] | ||||
| @@ -61,7 +61,7 @@ appender.rolling.type = RollingFile | ||||
| appender.rolling.name = FILE | ||||
| appender.rolling.layout.type = PatternLayout | ||||
| appender.rolling.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n | ||||
| appender.rolling.filePattern = ${dirname:-}${filename}.%i | ||||
| appender.rolling.filePattern = ./${filename}.%i | ||||
| appender.rolling.policy.type = SizeBasedTriggeringPolicy | ||||
| appender.rolling.policy.size = 4MB | ||||
| # Set the immediate flush to true (default) | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -12,7 +12,7 @@ configured paths, or create a dummy `D:` drive with the expected layout. | ||||
|  | ||||
| Typical build procedure: | ||||
|  | ||||
| * Overwrite the `qortal.jar` file in `Install-Files\` | ||||
| * Place the `qortal.jar` file in `Install-Files\` | ||||
| * Open AdvancedInstaller with qortal.aip file | ||||
| * If releasing a new version, change version number in: | ||||
| 	+ "Product Information" side menu | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								WindowsInstaller/qortal.ico
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								WindowsInstaller/qortal.ico
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 42 KiB | 
| @@ -61,7 +61,7 @@ appender.rolling.type = RollingFile | ||||
| appender.rolling.name = FILE | ||||
| appender.rolling.layout.type = PatternLayout | ||||
| appender.rolling.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n | ||||
| appender.rolling.filePattern = ${dirname:-}${filename}.%i | ||||
| appender.rolling.filePattern = ./${filename}.%i | ||||
| appender.rolling.policy.type = SizeBasedTriggeringPolicy | ||||
| appender.rolling.policy.size = 4MB | ||||
| # Set the immediate flush to true (default) | ||||
|   | ||||
							
								
								
									
										47
									
								
								pom.xml
									
									
									
									
									
								
							
							
						
						
									
										47
									
								
								pom.xml
									
									
									
									
									
								
							| @@ -3,28 +3,34 @@ | ||||
| 	<modelVersion>4.0.0</modelVersion> | ||||
| 	<groupId>org.qortal</groupId> | ||||
| 	<artifactId>qortal</artifactId> | ||||
| 	<version>1.5.1</version> | ||||
| 	<version>3.0.4</version> | ||||
| 	<packaging>jar</packaging> | ||||
| 	<properties> | ||||
| 		<skipTests>true</skipTests> | ||||
| 		<altcoinj.version>bf9fb80</altcoinj.version> | ||||
| 		<bitcoinj.version>0.15.6</bitcoinj.version> | ||||
| 		<bitcoinj.version>0.15.10</bitcoinj.version> | ||||
| 		<bouncycastle.version>1.64</bouncycastle.version> | ||||
| 		<build.timestamp>${maven.build.timestamp}</build.timestamp> | ||||
| 		<ciyam-at.version>1.3.8</ciyam-at.version> | ||||
| 		<commons-net.version>3.6</commons-net.version> | ||||
| 		<commons-text.version>1.8</commons-text.version> | ||||
| 		<commons-io.version>2.6</commons-io.version> | ||||
| 		<commons-compress.version>1.21</commons-compress.version> | ||||
| 		<commons-lang3.version>3.12.0</commons-lang3.version> | ||||
| 		<xz.version>1.9</xz.version> | ||||
| 		<dagger.version>1.2.2</dagger.version> | ||||
| 		<guava.version>28.1-jre</guava.version> | ||||
| 		<hsqldb.version>2.5.1</hsqldb.version> | ||||
| 		<jersey.version>2.29.1</jersey.version> | ||||
| 		<jetty.version>9.4.29.v20200521</jetty.version> | ||||
| 		<log4j.version>2.12.1</log4j.version> | ||||
| 		<log4j.version>2.17.1</log4j.version> | ||||
| 		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | ||||
| 		<slf4j.version>1.7.12</slf4j.version> | ||||
| 		<swagger-api.version>2.0.9</swagger-api.version> | ||||
| 		<swagger-ui.version>3.23.8</swagger-ui.version> | ||||
| 		<package-info-maven-plugin.version>1.1.0</package-info-maven-plugin.version> | ||||
| 		<jsoup.version>1.13.1</jsoup.version> | ||||
| 		<java-diff-utils.version>4.10</java-diff-utils.version> | ||||
| 	</properties> | ||||
| 	<build> | ||||
| 		<sourceDirectory>src/main/java</sourceDirectory> | ||||
| @@ -439,11 +445,36 @@ | ||||
| 			<artifactId>json-simple</artifactId> | ||||
| 			<version>1.1.1</version> | ||||
| 		</dependency> | ||||
| 		<dependency> | ||||
| 			<groupId>org.json</groupId> | ||||
| 			<artifactId>json</artifactId> | ||||
| 			<version>20210307</version> | ||||
| 		</dependency> | ||||
| 		<dependency> | ||||
| 			<groupId>org.apache.commons</groupId> | ||||
| 			<artifactId>commons-text</artifactId> | ||||
| 			<version>${commons-text.version}</version> | ||||
| 		</dependency> | ||||
| 		<dependency> | ||||
| 			<groupId>commons-io</groupId> | ||||
| 			<artifactId>commons-io</artifactId> | ||||
| 			<version>${commons-io.version}</version> | ||||
| 		</dependency> | ||||
| 		<dependency> | ||||
| 			<groupId>org.apache.commons</groupId> | ||||
| 			<artifactId>commons-compress</artifactId> | ||||
| 			<version>${commons-compress.version}</version> | ||||
| 		</dependency> | ||||
| 		<dependency> | ||||
| 			<groupId>org.apache.commons</groupId> | ||||
| 			<artifactId>commons-lang3</artifactId> | ||||
| 			<version>${commons-lang3.version}</version> | ||||
| 		</dependency> | ||||
| 		<dependency> | ||||
| 			<groupId>org.tukaani</groupId> | ||||
| 			<artifactId>xz</artifactId> | ||||
| 			<version>${xz.version}</version> | ||||
| 		</dependency> | ||||
| 		<!--  For bitset/bitmap compression --> | ||||
| 		<dependency> | ||||
| 			<groupId>io.druid</groupId> | ||||
| @@ -644,5 +675,15 @@ | ||||
| 			<artifactId>bctls-jdk15on</artifactId> | ||||
| 			<version>${bouncycastle.version}</version> | ||||
| 		</dependency> | ||||
| 		<dependency> | ||||
| 			<groupId>org.jsoup</groupId> | ||||
| 			<artifactId>jsoup</artifactId> | ||||
| 			<version>${jsoup.version}</version> | ||||
| 		</dependency> | ||||
| 		<dependency> | ||||
| 			<groupId>io.github.java-diff-utils</groupId> | ||||
| 			<artifactId>java-diff-utils</artifactId> | ||||
| 			<version>${java-diff-utils.version}</version> | ||||
| 		</dependency> | ||||
| 	</dependencies> | ||||
| </project> | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								src/.DS_Store
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/.DS_Store
									
									
									
									
										vendored
									
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								src/main/.DS_Store
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/main/.DS_Store
									
									
									
									
										vendored
									
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -7,14 +7,13 @@ import java.nio.file.Path; | ||||
| import java.nio.file.Paths; | ||||
| import java.nio.file.StandardCopyOption; | ||||
| import java.security.Security; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.List; | ||||
| import java.util.*; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.bouncycastle.jce.provider.BouncyCastleProvider; | ||||
| import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; | ||||
| import org.qortal.api.ApiKey; | ||||
| import org.qortal.api.ApiRequest; | ||||
| import org.qortal.controller.AutoUpdate; | ||||
| import org.qortal.settings.Settings; | ||||
| @@ -70,14 +69,40 @@ public class ApplyUpdate { | ||||
| 		String baseUri = "http://localhost:" + Settings.getInstance().getApiPort() + "/"; | ||||
| 		LOGGER.info(() -> String.format("Shutting down node using API via %s", baseUri)); | ||||
|  | ||||
| 		// The /admin/stop endpoint requires an API key, which may or may not be already generated | ||||
| 		boolean apiKeyNewlyGenerated = false; | ||||
| 		ApiKey apiKey = null; | ||||
| 		try { | ||||
| 			apiKey = new ApiKey(); | ||||
| 			if (!apiKey.generated()) { | ||||
| 				apiKey.generate(); | ||||
| 				apiKeyNewlyGenerated = true; | ||||
| 				LOGGER.info("Generated API key"); | ||||
| 			} | ||||
| 		} catch (IOException e) { | ||||
| 			LOGGER.info("Error loading API key: {}", e.getMessage()); | ||||
| 		} | ||||
|  | ||||
| 		// Create GET params | ||||
| 		Map<String, String> params = new HashMap<>(); | ||||
| 		if (apiKey != null) { | ||||
| 			params.put("apiKey", apiKey.toString()); | ||||
| 		} | ||||
|  | ||||
| 		// Attempt to stop the node | ||||
| 		int attempt; | ||||
| 		for (attempt = 0; attempt < MAX_ATTEMPTS; ++attempt) { | ||||
| 			final int attemptForLogging = attempt; | ||||
| 			LOGGER.info(() -> String.format("Attempt #%d out of %d to shutdown node", attemptForLogging + 1, MAX_ATTEMPTS)); | ||||
| 			String response = ApiRequest.perform(baseUri + "admin/stop", null); | ||||
| 			if (response == null) | ||||
| 			String response = ApiRequest.perform(baseUri + "admin/stop", params); | ||||
| 			if (response == null) { | ||||
| 				// No response - consider node shut down | ||||
| 				if (apiKeyNewlyGenerated) { | ||||
| 					// API key was newly generated for this auto update, so we need to remove it | ||||
| 					ApplyUpdate.removeGeneratedApiKey(); | ||||
| 				} | ||||
| 				return true; | ||||
| 			} | ||||
|  | ||||
| 			LOGGER.info(() -> String.format("Response from API: %s", response)); | ||||
|  | ||||
| @@ -89,6 +114,11 @@ public class ApplyUpdate { | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if (apiKeyNewlyGenerated) { | ||||
| 			// API key was newly generated for this auto update, so we need to remove it | ||||
| 			ApplyUpdate.removeGeneratedApiKey(); | ||||
| 		} | ||||
|  | ||||
| 		if (attempt == MAX_ATTEMPTS) { | ||||
| 			LOGGER.error("Failed to shutdown node - giving up"); | ||||
| 			return false; | ||||
| @@ -97,6 +127,19 @@ public class ApplyUpdate { | ||||
| 		return true; | ||||
| 	} | ||||
|  | ||||
| 	private static void removeGeneratedApiKey() { | ||||
| 		try { | ||||
| 			LOGGER.info("Removing newly generated API key..."); | ||||
|  | ||||
| 			// Delete the API key since it was only generated for this auto update | ||||
| 			ApiKey apiKey = new ApiKey(); | ||||
| 			apiKey.delete(); | ||||
|  | ||||
| 		} catch (IOException e) { | ||||
| 			LOGGER.info("Error loading or deleting API key: {}", e.getMessage()); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private static void replaceJar() { | ||||
| 		// Assuming current working directory contains the JAR files | ||||
| 		Path realJar = Paths.get(JAR_FILENAME); | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package org.qortal; | ||||
|  | ||||
| import java.security.Security; | ||||
| import java.util.concurrent.TimeoutException; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| @@ -57,10 +58,10 @@ public class RepositoryMaintenance { | ||||
|  | ||||
| 		LOGGER.info("Starting repository periodic maintenance. This can take a while..."); | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			repository.performPeriodicMaintenance(); | ||||
| 			repository.performPeriodicMaintenance(null); | ||||
|  | ||||
| 			LOGGER.info("Repository periodic maintenance completed"); | ||||
| 		} catch (DataException e) { | ||||
| 		} catch (DataException | TimeoutException e) { | ||||
| 			LOGGER.error("Repository periodic maintenance failed", e); | ||||
| 		} | ||||
|  | ||||
|   | ||||
| @@ -129,7 +129,14 @@ public enum ApiError { | ||||
| 	// Foreign blockchain | ||||
| 	FOREIGN_BLOCKCHAIN_NETWORK_ISSUE(1201, 500), | ||||
| 	FOREIGN_BLOCKCHAIN_BALANCE_ISSUE(1202, 402), | ||||
| 	FOREIGN_BLOCKCHAIN_TOO_SOON(1203, 408); | ||||
| 	FOREIGN_BLOCKCHAIN_TOO_SOON(1203, 408), | ||||
|  | ||||
| 	// Trade portal | ||||
| 	ORDER_SIZE_TOO_SMALL(1300, 402), | ||||
|  | ||||
| 	// Data | ||||
| 	FILE_NOT_FOUND(1401, 404), | ||||
| 	NO_REPLY(1402, 404); | ||||
|  | ||||
| 	private static final Map<Integer, ApiError> map = stream(ApiError.values()).collect(toMap(apiError -> apiError.code, apiError -> apiError)); | ||||
|  | ||||
| @@ -157,4 +164,4 @@ public enum ApiError { | ||||
| 		return this.status; | ||||
| 	} | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -16,4 +16,8 @@ public enum ApiExceptionFactory { | ||||
| 		return createException(request, apiError, null); | ||||
| 	} | ||||
|  | ||||
| 	public ApiException createCustomException(HttpServletRequest request, ApiError apiError, String message) { | ||||
| 		return new ApiException(apiError.getStatus(), apiError.getCode(), message, null); | ||||
| 	} | ||||
|  | ||||
| } | ||||
|   | ||||
							
								
								
									
										107
									
								
								src/main/java/org/qortal/api/ApiKey.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								src/main/java/org/qortal/api/ApiKey.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,107 @@ | ||||
| package org.qortal.api; | ||||
|  | ||||
| import org.qortal.settings.Settings; | ||||
| import org.qortal.utils.Base58; | ||||
|  | ||||
| import java.io.BufferedWriter; | ||||
| import java.io.File; | ||||
| import java.io.FileWriter; | ||||
| import java.io.IOException; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.Path; | ||||
| import java.nio.file.Paths; | ||||
| import java.security.SecureRandom; | ||||
|  | ||||
| public class ApiKey { | ||||
|  | ||||
|     private String apiKey; | ||||
|  | ||||
|     public ApiKey() throws IOException { | ||||
|         this.load(); | ||||
|     } | ||||
|  | ||||
|     public void generate() throws IOException { | ||||
|         byte[] apiKey = new byte[16]; | ||||
|         new SecureRandom().nextBytes(apiKey); | ||||
|         this.apiKey = Base58.encode(apiKey); | ||||
|  | ||||
|         this.save(); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     /* Filesystem */ | ||||
|  | ||||
|     private Path getFilePath() { | ||||
|         return Paths.get(Settings.getInstance().getApiKeyPath(), "apikey.txt"); | ||||
|     } | ||||
|  | ||||
|     private boolean load() throws IOException { | ||||
|         Path path = this.getFilePath(); | ||||
|         File apiKeyFile = new File(path.toString()); | ||||
|         if (!apiKeyFile.exists()) { | ||||
|             // Try settings - to allow legacy API keys to be supported | ||||
|             return this.loadLegacyApiKey(); | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             this.apiKey = new String(Files.readAllBytes(path)); | ||||
|  | ||||
|         } catch (IOException e) { | ||||
|             throw new IOException(String.format("Couldn't read contents from file %s", path.toString())); | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private boolean loadLegacyApiKey() { | ||||
|         String legacyApiKey = Settings.getInstance().getApiKey(); | ||||
|         if (legacyApiKey != null && !legacyApiKey.isEmpty()) { | ||||
|             this.apiKey = Settings.getInstance().getApiKey(); | ||||
|  | ||||
|             try { | ||||
|                 // Save it to the apikey file | ||||
|                 this.save(); | ||||
|             } catch (IOException e) { | ||||
|                 // Ignore failures as it will be reloaded from settings next time | ||||
|             } | ||||
|             return true; | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public void save() throws IOException { | ||||
|         if (this.apiKey == null || this.apiKey.isEmpty()) { | ||||
|             throw new IllegalStateException("Unable to save a blank API key"); | ||||
|         } | ||||
|  | ||||
|         Path filePath = this.getFilePath(); | ||||
|  | ||||
|         BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toString())); | ||||
|         writer.write(this.apiKey); | ||||
|         writer.close(); | ||||
|     } | ||||
|  | ||||
|     public void delete() throws IOException { | ||||
|         this.apiKey = null; | ||||
|  | ||||
|         Path filePath = this.getFilePath(); | ||||
|         if (Files.exists(filePath)) { | ||||
|             Files.delete(filePath); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public boolean generated() { | ||||
|         return (this.apiKey != null); | ||||
|     } | ||||
|  | ||||
|     public boolean exists() { | ||||
|         return this.getFilePath().toFile().exists(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public String toString() { | ||||
|         return this.apiKey; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -14,6 +14,7 @@ import java.security.SecureRandom; | ||||
| import javax.net.ssl.KeyManagerFactory; | ||||
| import javax.net.ssl.SSLContext; | ||||
|  | ||||
| import org.checkerframework.checker.units.qual.A; | ||||
| import org.eclipse.jetty.http.HttpVersion; | ||||
| import org.eclipse.jetty.rewrite.handler.RedirectPatternRule; | ||||
| import org.eclipse.jetty.rewrite.handler.RewriteHandler; | ||||
| @@ -54,6 +55,7 @@ public class ApiService { | ||||
|  | ||||
| 	private final ResourceConfig config; | ||||
| 	private Server server; | ||||
| 	private ApiKey apiKey; | ||||
|  | ||||
| 	private ApiService() { | ||||
| 		this.config = new ResourceConfig(); | ||||
| @@ -74,6 +76,15 @@ public class ApiService { | ||||
| 		return this.config.getClasses(); | ||||
| 	} | ||||
|  | ||||
| 	public void setApiKey(ApiKey apiKey) { | ||||
| 		this.apiKey = apiKey; | ||||
| 	} | ||||
|  | ||||
| 	public ApiKey getApiKey() { | ||||
| 		return this.apiKey; | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	public void start() { | ||||
| 		try { | ||||
| 			// Create API server | ||||
|   | ||||
| @@ -2,7 +2,7 @@ package org.qortal.api; | ||||
|  | ||||
| import javax.xml.bind.annotation.adapters.XmlAdapter; | ||||
|  | ||||
| import org.bitcoinj.core.Base58; | ||||
| import org.qortal.utils.Base58; | ||||
|  | ||||
| public class Base58TypeAdapter extends XmlAdapter<String, byte[]> { | ||||
|  | ||||
|   | ||||
							
								
								
									
										171
									
								
								src/main/java/org/qortal/api/DomainMapService.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								src/main/java/org/qortal/api/DomainMapService.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,171 @@ | ||||
| package org.qortal.api; | ||||
|  | ||||
| import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource; | ||||
| import org.eclipse.jetty.http.HttpVersion; | ||||
| import org.eclipse.jetty.rewrite.handler.RewriteHandler; | ||||
| import org.eclipse.jetty.rewrite.handler.RewritePatternRule; | ||||
| import org.eclipse.jetty.server.*; | ||||
| import org.eclipse.jetty.server.handler.ErrorHandler; | ||||
| import org.eclipse.jetty.server.handler.InetAccessHandler; | ||||
| 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.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 DomainMapService { | ||||
|  | ||||
| 	private static DomainMapService instance; | ||||
|  | ||||
| 	private final ResourceConfig config; | ||||
| 	private Server server; | ||||
|  | ||||
| 	private DomainMapService() { | ||||
| 		this.config = new ResourceConfig(); | ||||
| 		this.config.packages("org.qortal.api.domainmap.resource"); | ||||
| 		this.config.register(OpenApiResource.class); | ||||
| 		this.config.register(ApiDefinition.class); | ||||
| 		this.config.register(AnnotationPostProcessor.class); | ||||
| 	} | ||||
|  | ||||
| 	public static DomainMapService getInstance() { | ||||
| 		if (instance == null) | ||||
| 			instance = new DomainMapService(); | ||||
|  | ||||
| 		return instance; | ||||
| 	} | ||||
|  | ||||
| 	public Iterable<Class<?>> getResources() { | ||||
| 		return this.config.getClasses(); | ||||
| 	} | ||||
|  | ||||
| 	public void start() { | ||||
| 		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().getDomainMapPort()); | ||||
|  | ||||
| 				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(Settings.getInstance().getBindAddress()); | ||||
| 				portUnifiedConnector.setPort(Settings.getInstance().getDomainMapPort()); | ||||
|  | ||||
| 				this.server.addConnector(portUnifiedConnector); | ||||
| 			} else { | ||||
| 				// Non-SSL | ||||
| 				InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress()); | ||||
| 				InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getDomainMapPort()); | ||||
| 				this.server = new Server(endpoint); | ||||
| 			} | ||||
|  | ||||
| 			// Error handler | ||||
| 			ErrorHandler errorHandler = new ApiErrorHandler(); | ||||
| 			this.server.setErrorHandler(errorHandler); | ||||
|  | ||||
| 			// Request logging | ||||
| 			if (Settings.getInstance().isDomainMapLoggingEnabled()) { | ||||
| 				RequestLogWriter logWriter = new RequestLogWriter("domainmap-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 RuntimeException("Failed to start API", e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public void stop() { | ||||
| 		try { | ||||
| 			// Stop server | ||||
| 			this.server.stop(); | ||||
| 		} catch (Exception e) { | ||||
| 			// Failed to stop | ||||
| 		} | ||||
|  | ||||
| 		this.server = null; | ||||
| 	} | ||||
|  | ||||
| } | ||||
							
								
								
									
										170
									
								
								src/main/java/org/qortal/api/GatewayService.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								src/main/java/org/qortal/api/GatewayService.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,170 @@ | ||||
| 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.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 GatewayService { | ||||
|  | ||||
| 	private static GatewayService instance; | ||||
|  | ||||
| 	private final ResourceConfig config; | ||||
| 	private Server server; | ||||
|  | ||||
| 	private GatewayService() { | ||||
| 		this.config = new ResourceConfig(); | ||||
| 		this.config.packages("org.qortal.api.gateway.resource"); | ||||
| 		this.config.register(OpenApiResource.class); | ||||
| 		this.config.register(ApiDefinition.class); | ||||
| 		this.config.register(AnnotationPostProcessor.class); | ||||
| 	} | ||||
|  | ||||
| 	public static GatewayService getInstance() { | ||||
| 		if (instance == null) | ||||
| 			instance = new GatewayService(); | ||||
|  | ||||
| 		return instance; | ||||
| 	} | ||||
|  | ||||
| 	public Iterable<Class<?>> getResources() { | ||||
| 		return this.config.getClasses(); | ||||
| 	} | ||||
|  | ||||
| 	public void start() { | ||||
| 		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().getGatewayPort()); | ||||
|  | ||||
| 				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(Settings.getInstance().getBindAddress()); | ||||
| 				portUnifiedConnector.setPort(Settings.getInstance().getGatewayPort()); | ||||
|  | ||||
| 				this.server.addConnector(portUnifiedConnector); | ||||
| 			} else { | ||||
| 				// Non-SSL | ||||
| 				InetAddress bindAddr = InetAddress.getByName(Settings.getInstance().getBindAddress()); | ||||
| 				InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getGatewayPort()); | ||||
| 				this.server = new Server(endpoint); | ||||
| 			} | ||||
|  | ||||
| 			// Error handler | ||||
| 			ErrorHandler errorHandler = new ApiErrorHandler(); | ||||
| 			this.server.setErrorHandler(errorHandler); | ||||
|  | ||||
| 			// Request logging | ||||
| 			if (Settings.getInstance().isGatewayLoggingEnabled()) { | ||||
| 				RequestLogWriter logWriter = new RequestLogWriter("gateway-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 RuntimeException("Failed to start API", e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public void stop() { | ||||
| 		try { | ||||
| 			// Stop server | ||||
| 			this.server.stop(); | ||||
| 		} catch (Exception e) { | ||||
| 			// Failed to stop | ||||
| 		} | ||||
|  | ||||
| 		this.server = null; | ||||
| 	} | ||||
|  | ||||
| } | ||||
							
								
								
									
										46
									
								
								src/main/java/org/qortal/api/HTMLParser.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/main/java/org/qortal/api/HTMLParser.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| package org.qortal.api; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.jsoup.Jsoup; | ||||
| import org.jsoup.nodes.Document; | ||||
| import org.jsoup.select.Elements; | ||||
|  | ||||
| public class HTMLParser { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(HTMLParser.class); | ||||
|  | ||||
|     private String linkPrefix; | ||||
|     private byte[] data; | ||||
|  | ||||
|     public HTMLParser(String resourceId, String inPath, String prefix, boolean usePrefix, byte[] data) { | ||||
|         String inPathWithoutFilename = inPath.substring(0, inPath.lastIndexOf('/')); | ||||
|         this.linkPrefix = usePrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : ""; | ||||
|         this.data = data; | ||||
|     } | ||||
|  | ||||
|     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 base href tag | ||||
|             String baseElement = String.format("<base href=\"%s\">", baseUrl); | ||||
|             head.get(0).prepend(baseElement); | ||||
|         } | ||||
|         String html = document.html(); | ||||
|         this.data = html.getBytes(); | ||||
|     } | ||||
|  | ||||
|     public static boolean isHtmlFile(String path) { | ||||
|         if (path.endsWith(".html") || path.endsWith(".htm")) { | ||||
|             return true; | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public byte[] getData() { | ||||
|         return this.data; | ||||
|     } | ||||
| } | ||||
| @@ -1,33 +1,111 @@ | ||||
| package org.qortal.api; | ||||
|  | ||||
| import org.qortal.arbitrary.ArbitraryDataResource; | ||||
| import org.qortal.arbitrary.misc.Service; | ||||
| import org.qortal.controller.arbitrary.ArbitraryDataRenderManager; | ||||
| import org.qortal.settings.Settings; | ||||
|  | ||||
| import java.io.IOException; | ||||
| import java.net.InetAddress; | ||||
| import java.net.UnknownHostException; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
|  | ||||
| import org.qortal.settings.Settings; | ||||
|  | ||||
| public abstract class Security { | ||||
|  | ||||
| 	public static final String API_KEY_HEADER = "X-API-KEY"; | ||||
|  | ||||
| 	public static void checkApiCallAllowed(HttpServletRequest request) { | ||||
| 		String expectedApiKey = Settings.getInstance().getApiKey(); | ||||
| 		// We may want to allow automatic authentication for local requests, if enabled in settings | ||||
| 		boolean localAuthBypassEnabled = Settings.getInstance().isLocalAuthBypassEnabled(); | ||||
| 		if (localAuthBypassEnabled) { | ||||
| 			try { | ||||
| 				InetAddress remoteAddr = InetAddress.getByName(request.getRemoteAddr()); | ||||
| 				if (remoteAddr.isLoopbackAddress()) { | ||||
| 					// Request originates from loopback address, so allow it | ||||
| 					return; | ||||
| 				} | ||||
| 			} catch (UnknownHostException e) { | ||||
| 				// Ignore failure, and fallback to API key authentication | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Retrieve the API key | ||||
| 		ApiKey apiKey = Security.getApiKey(request); | ||||
| 		if (!apiKey.generated()) { | ||||
| 			// Not generated an API key yet, so disallow sensitive API calls | ||||
| 			throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "API key not generated"); | ||||
| 		} | ||||
|  | ||||
| 		// We require an API key to be passed | ||||
| 		String passedApiKey = request.getHeader(API_KEY_HEADER); | ||||
| 		if (passedApiKey == null) { | ||||
| 			// Try query string - this is needed to avoid a CORS preflight. See: https://stackoverflow.com/a/43881141 | ||||
| 			passedApiKey = request.getParameter("apiKey"); | ||||
| 		} | ||||
| 		if (passedApiKey == null) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "Missing 'X-API-KEY' header"); | ||||
| 		} | ||||
|  | ||||
| 		if ((expectedApiKey != null && !expectedApiKey.equals(passedApiKey)) || | ||||
| 				(passedApiKey != null && !passedApiKey.equals(expectedApiKey))) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED); | ||||
| 		// The API keys must match | ||||
| 		if (!apiKey.toString().equals(passedApiKey)) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "API key invalid"); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 		InetAddress remoteAddr; | ||||
| 	public static void disallowLoopbackRequests(HttpServletRequest request) { | ||||
| 		try { | ||||
| 			remoteAddr = InetAddress.getByName(request.getRemoteAddr()); | ||||
| 			InetAddress remoteAddr = InetAddress.getByName(request.getRemoteAddr()); | ||||
| 			if (remoteAddr.isLoopbackAddress()) { | ||||
| 				throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "Local requests not allowed"); | ||||
| 			} | ||||
| 		} catch (UnknownHostException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 		if (!remoteAddr.isLoopbackAddress()) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED); | ||||
| 	public static void disallowLoopbackRequestsIfAuthBypassEnabled(HttpServletRequest request) { | ||||
| 		if (Settings.getInstance().isLocalAuthBypassEnabled()) { | ||||
| 			try { | ||||
| 				InetAddress remoteAddr = InetAddress.getByName(request.getRemoteAddr()); | ||||
| 				if (remoteAddr.isLoopbackAddress()) { | ||||
| 					throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "Local requests not allowed when localAuthBypassEnabled is enabled in settings"); | ||||
| 				} | ||||
| 			} catch (UnknownHostException e) { | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public static void requirePriorAuthorization(HttpServletRequest request, String resourceId, Service service, String identifier) { | ||||
| 		ArbitraryDataResource resource = new ArbitraryDataResource(resourceId, null, service, identifier); | ||||
| 		if (!ArbitraryDataRenderManager.getInstance().isAuthorized(resource)) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "Call /render/authorize first"); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public static void requirePriorAuthorizationOrApiKey(HttpServletRequest request, String resourceId, Service service, String identifier) { | ||||
| 		try { | ||||
| 			Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		} catch (ApiException e) { | ||||
| 			// API call wasn't allowed, but maybe it was pre-authorized | ||||
| 			Security.requirePriorAuthorization(request, resourceId, service, identifier); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public static ApiKey getApiKey(HttpServletRequest request) { | ||||
| 		ApiKey apiKey = ApiService.getInstance().getApiKey(); | ||||
| 		if (apiKey == null) { | ||||
| 			try { | ||||
| 				apiKey = new ApiKey(); | ||||
| 			} catch (IOException e) { | ||||
| 				// Couldn't load API key - so we need to treat it as not generated, and therefore unauthorized | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED); | ||||
| 			} | ||||
| 			ApiService.getInstance().setApiKey(apiKey); | ||||
| 		} | ||||
| 		return apiKey; | ||||
| 	} | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,58 @@ | ||||
| package org.qortal.api.domainmap.resource; | ||||
|  | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
| import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType; | ||||
| import org.qortal.arbitrary.ArbitraryDataRenderer; | ||||
| import org.qortal.arbitrary.misc.Service; | ||||
| import org.qortal.settings.Settings; | ||||
|  | ||||
| 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.util.Map; | ||||
|  | ||||
|  | ||||
| @Path("/") | ||||
| @Tag(name = "Gateway") | ||||
| public class DomainMapResource { | ||||
|  | ||||
|     @Context HttpServletRequest request; | ||||
|     @Context HttpServletResponse response; | ||||
|     @Context ServletContext context; | ||||
|  | ||||
|  | ||||
|     @GET | ||||
|     public HttpServletResponse getIndexByDomainMap() { | ||||
|         return this.getDomainMap("/"); | ||||
|     } | ||||
|  | ||||
|     @GET | ||||
|     @Path("{path:.*}") | ||||
|     public HttpServletResponse getPathByDomainMap(@PathParam("path") String inPath) { | ||||
|         return this.getDomainMap(inPath); | ||||
|     } | ||||
|  | ||||
|     private HttpServletResponse getDomainMap(String inPath) { | ||||
|         Map<String, String> domainMap = Settings.getInstance().getSimpleDomainMap(); | ||||
|         if (domainMap != null && domainMap.containsKey(request.getServerName())) { | ||||
|             // Build synchronously, so that we don't need to make the summary API endpoints available over | ||||
|             // the domain map server. This means that there will be no loading screen, but this is potentially | ||||
|             // preferred in this situation anyway (e.g. to avoid confusing search engine robots). | ||||
|             return this.get(domainMap.get(request.getServerName()), ResourceIdType.NAME, Service.WEBSITE, inPath, null, "", false, false); | ||||
|         } | ||||
|         return ArbitraryDataRenderer.getResponse(response, 404, "Error 404: File Not Found"); | ||||
|     } | ||||
|  | ||||
|     private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String inPath, | ||||
|                                     String secret58, String prefix, boolean usePrefix, boolean async) { | ||||
|  | ||||
|         ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath, | ||||
|                 secret58, prefix, usePrefix, async, request, response, context); | ||||
|         return renderer.render(); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,126 @@ | ||||
| package org.qortal.api.gateway.resource; | ||||
|  | ||||
| import io.swagger.v3.oas.annotations.security.SecurityRequirement; | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
| import org.qortal.api.Security; | ||||
| import org.qortal.arbitrary.ArbitraryDataFile; | ||||
| import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType; | ||||
| import org.qortal.arbitrary.ArbitraryDataReader; | ||||
| import org.qortal.arbitrary.ArbitraryDataRenderer; | ||||
| import org.qortal.arbitrary.ArbitraryDataResource; | ||||
| import org.qortal.arbitrary.misc.Service; | ||||
| import org.qortal.data.arbitrary.ArbitraryResourceStatus; | ||||
|  | ||||
| import javax.servlet.ServletContext; | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.servlet.http.HttpServletResponse; | ||||
| import javax.ws.rs.*; | ||||
| import javax.ws.rs.core.Context; | ||||
|  | ||||
|  | ||||
| @Path("/") | ||||
| @Tag(name = "Gateway") | ||||
| public class GatewayResource { | ||||
|  | ||||
|     @Context HttpServletRequest request; | ||||
|     @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 { | ||||
|                 if (!reader.isBuilding()) { | ||||
|                     reader.loadSynchronously(false); | ||||
|                 } | ||||
|             } catch (Exception e) { | ||||
|                 // No need to handle exception, as it will be reflected in the status | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         ArbitraryDataResource resource = new ArbitraryDataResource(name, ResourceIdType.NAME, service, identifier); | ||||
|         return resource.getStatus(false); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     @GET | ||||
|     public HttpServletResponse getRoot() { | ||||
|         return ArbitraryDataRenderer.getResponse(response, 200, ""); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     @GET | ||||
|     @Path("{name}/{path:.*}") | ||||
|     @SecurityRequirement(name = "apiKey") | ||||
|     public HttpServletResponse getPathByName(@PathParam("name") String name, | ||||
|                                              @PathParam("path") String inPath) { | ||||
|         // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data | ||||
|         Security.disallowLoopbackRequests(request); | ||||
|         return this.get(name, ResourceIdType.NAME, Service.WEBSITE, inPath, null, "", true, true); | ||||
|     } | ||||
|  | ||||
|     @GET | ||||
|     @Path("{name}") | ||||
|     @SecurityRequirement(name = "apiKey") | ||||
|     public HttpServletResponse getIndexByName(@PathParam("name") String name) { | ||||
|         // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data | ||||
|         Security.disallowLoopbackRequests(request); | ||||
|         return this.get(name, ResourceIdType.NAME, Service.WEBSITE, "/", null, "", true, true); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     // Optional /site alternative for backwards support | ||||
|  | ||||
|     @GET | ||||
|     @Path("/site/{name}/{path:.*}") | ||||
|     public HttpServletResponse getSitePathByName(@PathParam("name") String name, | ||||
|                                                  @PathParam("path") String inPath) { | ||||
|         // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data | ||||
|         Security.disallowLoopbackRequests(request); | ||||
|         return this.get(name, ResourceIdType.NAME, Service.WEBSITE, inPath, null, "/site", true, true); | ||||
|     } | ||||
|  | ||||
|     @GET | ||||
|     @Path("/site/{name}") | ||||
|     public HttpServletResponse getSiteIndexByName(@PathParam("name") String name) { | ||||
|         // Block requests from localhost, to prevent websites/apps from running javascript that fetches unvetted data | ||||
|         Security.disallowLoopbackRequests(request); | ||||
|         return this.get(name, ResourceIdType.NAME, Service.WEBSITE, "/", null, "/site", true, true); | ||||
|     } | ||||
|  | ||||
|      | ||||
|     private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String inPath, | ||||
|                                     String secret58, String prefix, boolean usePrefix, boolean async) { | ||||
|  | ||||
|         ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath, | ||||
|                 secret58, prefix, usePrefix, async, request, response, context); | ||||
|         return renderer.render(); | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										23
									
								
								src/main/java/org/qortal/api/model/BlockMintingInfo.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/main/java/org/qortal/api/model/BlockMintingInfo.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| package org.qortal.api.model; | ||||
|  | ||||
| import javax.xml.bind.annotation.XmlAccessType; | ||||
| import javax.xml.bind.annotation.XmlAccessorType; | ||||
| import java.math.BigDecimal; | ||||
| import java.math.BigInteger; | ||||
|  | ||||
| @XmlAccessorType(XmlAccessType.FIELD) | ||||
| public class BlockMintingInfo { | ||||
|  | ||||
| 	public byte[] minterPublicKey; | ||||
| 	public int minterLevel; | ||||
| 	public int onlineAccountsCount; | ||||
| 	public BigDecimal maxDistance; | ||||
| 	public BigInteger keyDistance; | ||||
| 	public double keyDistanceRatio; | ||||
| 	public long timestamp; | ||||
| 	public long timeDelta; | ||||
|  | ||||
| 	public BlockMintingInfo() { | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @@ -1,61 +1,74 @@ | ||||
| package org.qortal.api.model; | ||||
|  | ||||
| import javax.xml.bind.annotation.XmlAccessType; | ||||
| import javax.xml.bind.annotation.XmlAccessorType; | ||||
|  | ||||
| import io.swagger.v3.oas.annotations.media.Schema; | ||||
| import org.qortal.data.network.PeerChainTipData; | ||||
| import org.qortal.data.network.PeerData; | ||||
| import org.qortal.network.Handshake; | ||||
| import org.qortal.network.Peer; | ||||
|  | ||||
| import io.swagger.v3.oas.annotations.media.Schema; | ||||
| import javax.xml.bind.annotation.XmlAccessType; | ||||
| import javax.xml.bind.annotation.XmlAccessorType; | ||||
| import java.util.UUID; | ||||
| import java.util.concurrent.TimeUnit; | ||||
|  | ||||
| @XmlAccessorType(XmlAccessType.FIELD) | ||||
| public class ConnectedPeer { | ||||
|  | ||||
| 	public enum Direction { | ||||
| 		INBOUND, | ||||
| 		OUTBOUND; | ||||
| 	} | ||||
| 	public Direction direction; | ||||
| 	public Handshake handshakeStatus; | ||||
| 	public Long lastPing; | ||||
| 	public Long connectedWhen; | ||||
| 	public Long peersConnectedWhen; | ||||
|     public enum Direction { | ||||
|         INBOUND, | ||||
|         OUTBOUND; | ||||
|     } | ||||
|  | ||||
| 	public String address; | ||||
| 	public String version; | ||||
|     public Direction direction; | ||||
|     public Handshake handshakeStatus; | ||||
|     public Long lastPing; | ||||
|     public Long connectedWhen; | ||||
|     public Long peersConnectedWhen; | ||||
|  | ||||
| 	public String nodeId; | ||||
|     public String address; | ||||
|     public String version; | ||||
|  | ||||
| 	public Integer lastHeight; | ||||
| 	@Schema(example = "base58") | ||||
| 	public byte[] lastBlockSignature; | ||||
| 	public Long lastBlockTimestamp; | ||||
|     public String nodeId; | ||||
|  | ||||
| 	protected ConnectedPeer() { | ||||
| 	} | ||||
|     public Integer lastHeight; | ||||
|     @Schema(example = "base58") | ||||
|     public byte[] lastBlockSignature; | ||||
|     public Long lastBlockTimestamp; | ||||
|     public UUID connectionId; | ||||
|     public String age; | ||||
|  | ||||
| 	public ConnectedPeer(Peer peer) { | ||||
| 		this.direction = peer.isOutbound() ? Direction.OUTBOUND : Direction.INBOUND; | ||||
| 		this.handshakeStatus = peer.getHandshakeStatus(); | ||||
| 		this.lastPing = peer.getLastPing(); | ||||
|     protected ConnectedPeer() { | ||||
|     } | ||||
|  | ||||
| 		PeerData peerData = peer.getPeerData(); | ||||
| 		this.connectedWhen = peer.getConnectionTimestamp(); | ||||
| 		this.peersConnectedWhen = peer.getPeersConnectionTimestamp(); | ||||
|     public ConnectedPeer(Peer peer) { | ||||
|         this.direction = peer.isOutbound() ? Direction.OUTBOUND : Direction.INBOUND; | ||||
|         this.handshakeStatus = peer.getHandshakeStatus(); | ||||
|         this.lastPing = peer.getLastPing(); | ||||
|  | ||||
| 		this.address = peerData.getAddress().toString(); | ||||
|         PeerData peerData = peer.getPeerData(); | ||||
|         this.connectedWhen = peer.getConnectionTimestamp(); | ||||
|         this.peersConnectedWhen = peer.getPeersConnectionTimestamp(); | ||||
|  | ||||
| 		this.version = peer.getPeersVersionString(); | ||||
| 		this.nodeId = peer.getPeersNodeId(); | ||||
|         this.address = peerData.getAddress().toString(); | ||||
|  | ||||
| 		PeerChainTipData peerChainTipData = peer.getChainTipData(); | ||||
| 		if (peerChainTipData != null) { | ||||
| 			this.lastHeight = peerChainTipData.getLastHeight(); | ||||
| 			this.lastBlockSignature = peerChainTipData.getLastBlockSignature(); | ||||
| 			this.lastBlockTimestamp = peerChainTipData.getLastBlockTimestamp(); | ||||
| 		} | ||||
| 	} | ||||
|         this.version = peer.getPeersVersionString(); | ||||
|         this.nodeId = peer.getPeersNodeId(); | ||||
|         this.connectionId = peer.getPeerConnectionId(); | ||||
|         if (peer.getConnectionEstablishedTime() > 0) { | ||||
|             long age = (System.currentTimeMillis() - peer.getConnectionEstablishedTime()); | ||||
|             long minutes = TimeUnit.MILLISECONDS.toMinutes(age); | ||||
|             long seconds = TimeUnit.MILLISECONDS.toSeconds(age) - TimeUnit.MINUTES.toSeconds(minutes); | ||||
|             this.age = String.format("%dm %ds", minutes, seconds); | ||||
|         } else { | ||||
|             this.age = "connecting..."; | ||||
|         } | ||||
|  | ||||
|         PeerChainTipData peerChainTipData = peer.getChainTipData(); | ||||
|         if (peerChainTipData != null) { | ||||
|             this.lastHeight = peerChainTipData.getLastHeight(); | ||||
|             this.lastBlockSignature = peerChainTipData.getLastBlockSignature(); | ||||
|             this.lastBlockTimestamp = peerChainTipData.getLastBlockTimestamp(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,29 @@ | ||||
| package org.qortal.api.model; | ||||
|  | ||||
| import io.swagger.v3.oas.annotations.media.Schema; | ||||
|  | ||||
| import javax.xml.bind.annotation.XmlAccessType; | ||||
| import javax.xml.bind.annotation.XmlAccessorType; | ||||
|  | ||||
| @XmlAccessorType(XmlAccessType.FIELD) | ||||
| public class CrossChainDualSecretRequest { | ||||
|  | ||||
| 	@Schema(description = "Public key to match AT's trade 'partner'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") | ||||
| 	public byte[] partnerPublicKey; | ||||
|  | ||||
| 	@Schema(description = "Qortal AT address") | ||||
| 	public String atAddress; | ||||
|  | ||||
| 	@Schema(description = "secret-A (32 bytes)", example = "FHMzten4he9jZ4HGb4297Utj6F5g2w7serjq2EnAg2s1") | ||||
| 	public byte[] secretA; | ||||
|  | ||||
| 	@Schema(description = "secret-B (32 bytes)", example = "EN2Bgx3BcEMtxFCewmCVSMkfZjVKYhx3KEXC5A21KBGx") | ||||
| 	public byte[] secretB; | ||||
|  | ||||
| 	@Schema(description = "Qortal address for receiving QORT from AT") | ||||
| 	public String receivingAddress; | ||||
|  | ||||
| 	public CrossChainDualSecretRequest() { | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @@ -8,17 +8,14 @@ import io.swagger.v3.oas.annotations.media.Schema; | ||||
| @XmlAccessorType(XmlAccessType.FIELD) | ||||
| public class CrossChainSecretRequest { | ||||
|  | ||||
| 	@Schema(description = "Public key to match AT's trade 'partner'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") | ||||
| 	public byte[] partnerPublicKey; | ||||
| 	@Schema(description = "Private key to match AT's trade 'partner'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") | ||||
| 	public byte[] partnerPrivateKey; | ||||
|  | ||||
| 	@Schema(description = "Qortal AT address") | ||||
| 	public String atAddress; | ||||
|  | ||||
| 	@Schema(description = "secret-A (32 bytes)", example = "FHMzten4he9jZ4HGb4297Utj6F5g2w7serjq2EnAg2s1") | ||||
| 	public byte[] secretA; | ||||
|  | ||||
| 	@Schema(description = "secret-B (32 bytes)", example = "EN2Bgx3BcEMtxFCewmCVSMkfZjVKYhx3KEXC5A21KBGx") | ||||
| 	public byte[] secretB; | ||||
| 	@Schema(description = "Secret (32 bytes)", example = "FHMzten4he9jZ4HGb4297Utj6F5g2w7serjq2EnAg2s1") | ||||
| 	public byte[] secret; | ||||
|  | ||||
| 	@Schema(description = "Qortal address for receiving QORT from AT") | ||||
| 	public String receivingAddress; | ||||
|   | ||||
| @@ -25,6 +25,12 @@ public class CrossChainTradeSummary { | ||||
| 	@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) | ||||
| 	private long foreignAmount; | ||||
|  | ||||
| 	private String atAddress; | ||||
|  | ||||
| 	private String sellerAddress; | ||||
|  | ||||
| 	private String buyerReceivingAddress; | ||||
|  | ||||
| 	protected CrossChainTradeSummary() { | ||||
| 		/* For JAXB */ | ||||
| 	} | ||||
| @@ -34,6 +40,9 @@ public class CrossChainTradeSummary { | ||||
| 		this.qortAmount = crossChainTradeData.qortAmount; | ||||
| 		this.foreignAmount = crossChainTradeData.expectedForeignAmount; | ||||
| 		this.btcAmount = this.foreignAmount; | ||||
| 		this.sellerAddress = crossChainTradeData.qortalCreator; | ||||
| 		this.buyerReceivingAddress = crossChainTradeData.qortalPartnerReceivingAddress; | ||||
| 		this.atAddress = crossChainTradeData.qortalAtAddress; | ||||
| 	} | ||||
|  | ||||
| 	public long getTradeTimestamp() { | ||||
| @@ -48,7 +57,11 @@ public class CrossChainTradeSummary { | ||||
| 		return this.btcAmount; | ||||
| 	} | ||||
|  | ||||
| 	public long getForeignAmount() { | ||||
| 		return this.foreignAmount; | ||||
| 	} | ||||
| 	public long getForeignAmount() { return this.foreignAmount; } | ||||
|  | ||||
| 	public String getAtAddress() { return this.atAddress; } | ||||
|  | ||||
| 	public String getSellerAddress() { return this.sellerAddress; } | ||||
|  | ||||
| 	public String getBuyerReceivingAddressAddress() { return this.buyerReceivingAddress; } | ||||
| } | ||||
|   | ||||
							
								
								
									
										18
									
								
								src/main/java/org/qortal/api/model/ListRequest.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/main/java/org/qortal/api/model/ListRequest.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| package org.qortal.api.model; | ||||
|  | ||||
| import io.swagger.v3.oas.annotations.media.Schema; | ||||
|  | ||||
| import javax.xml.bind.annotation.XmlAccessType; | ||||
| import javax.xml.bind.annotation.XmlAccessorType; | ||||
| import java.util.List; | ||||
|  | ||||
| @XmlAccessorType(XmlAccessType.FIELD) | ||||
| public class ListRequest { | ||||
|  | ||||
| 	@Schema(description = "A list of items") | ||||
| 	public List<String> items; | ||||
|  | ||||
| 	public ListRequest() { | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @@ -4,6 +4,7 @@ import javax.xml.bind.annotation.XmlAccessType; | ||||
| import javax.xml.bind.annotation.XmlAccessorType; | ||||
|  | ||||
| import org.qortal.controller.Controller; | ||||
| import org.qortal.controller.Synchronizer; | ||||
| import org.qortal.network.Network; | ||||
|  | ||||
| @XmlAccessorType(XmlAccessType.FIELD) | ||||
| @@ -22,7 +23,7 @@ public class NodeStatus { | ||||
| 	public NodeStatus() { | ||||
| 		this.isMintingPossible = Controller.getInstance().isMintingPossible(); | ||||
|  | ||||
| 		this.syncPercent = Controller.getInstance().getSyncPercent(); | ||||
| 		this.syncPercent = Synchronizer.getInstance().getSyncPercent(); | ||||
| 		this.isSynchronizing = this.syncPercent != null; | ||||
|  | ||||
| 		this.numberOfConnections = Network.getInstance().getHandshakedPeers().size(); | ||||
|   | ||||
							
								
								
									
										15
									
								
								src/main/java/org/qortal/api/model/PeersSummary.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/main/java/org/qortal/api/model/PeersSummary.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| package org.qortal.api.model; | ||||
|  | ||||
| import javax.xml.bind.annotation.XmlAccessType; | ||||
| import javax.xml.bind.annotation.XmlAccessorType; | ||||
|  | ||||
| @XmlAccessorType(XmlAccessType.FIELD) | ||||
| public class PeersSummary { | ||||
|  | ||||
| 	public int inboundConnections; | ||||
| 	public int outboundConnections; | ||||
|  | ||||
| 	public PeersSummary() { | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,29 @@ | ||||
| package org.qortal.api.model.crosschain; | ||||
|  | ||||
| import io.swagger.v3.oas.annotations.media.Schema; | ||||
|  | ||||
| import javax.xml.bind.annotation.XmlAccessType; | ||||
| import javax.xml.bind.annotation.XmlAccessorType; | ||||
| import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; | ||||
|  | ||||
| @XmlAccessorType(XmlAccessType.FIELD) | ||||
| public class DogecoinSendRequest { | ||||
|  | ||||
| 	@Schema(description = "Dogecoin BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________") | ||||
| 	public String xprv58; | ||||
|  | ||||
| 	@Schema(description = "Recipient's Dogecoin address ('legacy' P2PKH only)", example = "DoGecoinEaterAddressDontSendhLfzKD") | ||||
| 	public String receivingAddress; | ||||
|  | ||||
| 	@Schema(description = "Amount of DOGE to send", type = "number") | ||||
| 	@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) | ||||
| 	public long dogecoinAmount; | ||||
|  | ||||
| 	@Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 DOGE (100 sats) per byte", example = "0.00000100", type = "number") | ||||
| 	@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) | ||||
| 	public Long feePerByte; | ||||
|  | ||||
| 	public DogecoinSendRequest() { | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @@ -12,14 +12,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; | ||||
|  | ||||
| import java.math.BigDecimal; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Comparator; | ||||
| import java.util.List; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.GET; | ||||
| import javax.ws.rs.POST; | ||||
| import javax.ws.rs.Path; | ||||
| import javax.ws.rs.PathParam; | ||||
| import javax.ws.rs.QueryParam; | ||||
| import javax.ws.rs.*; | ||||
| import javax.ws.rs.core.Context; | ||||
| import javax.ws.rs.core.MediaType; | ||||
|  | ||||
| @@ -38,6 +35,7 @@ import org.qortal.crypto.Crypto; | ||||
| import org.qortal.data.account.AccountData; | ||||
| import org.qortal.data.account.RewardShareData; | ||||
| import org.qortal.data.network.OnlineAccountData; | ||||
| import org.qortal.data.network.OnlineAccountLevel; | ||||
| import org.qortal.data.transaction.PublicizeTransactionData; | ||||
| import org.qortal.data.transaction.RewardShareTransactionData; | ||||
| import org.qortal.data.transaction.TransactionData; | ||||
| @@ -180,6 +178,66 @@ public class AddressesResource { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@GET | ||||
| 	@Path("/online/levels") | ||||
| 	@Operation( | ||||
| 			summary = "Return currently 'online' accounts counts, grouped by level", | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							description = "online accounts", | ||||
| 							content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = ApiOnlineAccount.class))) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.PUBLIC_KEY_NOT_FOUND, ApiError.REPOSITORY_ISSUE}) | ||||
| 	public List<OnlineAccountLevel> getOnlineAccountsByLevel() { | ||||
| 		List<OnlineAccountData> onlineAccounts = Controller.getInstance().getOnlineAccounts(); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			List<OnlineAccountLevel> onlineAccountLevels = new ArrayList<>(); | ||||
|  | ||||
| 			for (OnlineAccountData onlineAccountData : onlineAccounts) { | ||||
| 				try { | ||||
| 					final int minterLevel = Account.getRewardShareEffectiveMintingLevel(repository, onlineAccountData.getPublicKey()); | ||||
|  | ||||
| 					OnlineAccountLevel onlineAccountLevel = onlineAccountLevels.stream() | ||||
| 							.filter(a -> a.getLevel() == minterLevel) | ||||
| 							.findFirst().orElse(null); | ||||
|  | ||||
| 					// Note: I don't think we can use the level as the List index here because there will be gaps. | ||||
| 					// So we are forced to manually look up the existing item each time. | ||||
| 					// There's probably a nice shorthand java way of doing this, but this approach gets the same result. | ||||
|  | ||||
| 					if (onlineAccountLevel == null) { | ||||
| 						// No entry exists for this level yet, so create one | ||||
| 						onlineAccountLevel = new OnlineAccountLevel(minterLevel, 1); | ||||
| 						onlineAccountLevels.add(onlineAccountLevel); | ||||
| 					} | ||||
| 					else { | ||||
| 						// Already exists - so increment the count | ||||
| 						int existingCount = onlineAccountLevel.getCount(); | ||||
| 						onlineAccountLevel.setCount(++existingCount); | ||||
|  | ||||
| 						// Then replace the existing item | ||||
| 						int index = onlineAccountLevels.indexOf(onlineAccountLevel); | ||||
| 						onlineAccountLevels.set(index, onlineAccountLevel); | ||||
| 					} | ||||
|  | ||||
| 				} catch (DataException e) { | ||||
| 					continue; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			// Sort by level | ||||
| 			onlineAccountLevels.sort(Comparator.comparingInt(OnlineAccountLevel::getLevel)); | ||||
|  | ||||
| 			return onlineAccountLevels; | ||||
|  | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@GET | ||||
| 	@Path("/balance/{address}") | ||||
| 	@Operation( | ||||
| @@ -475,7 +533,7 @@ public class AddressesResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String computePublicize(String rawBytes58) { | ||||
| 	public String computePublicize(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String rawBytes58) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|   | ||||
| @@ -22,32 +22,29 @@ import java.time.OffsetDateTime; | ||||
| import java.time.ZoneOffset; | ||||
| import java.util.List; | ||||
| import java.util.concurrent.TimeUnit; | ||||
| import java.util.concurrent.TimeoutException; | ||||
| import java.util.concurrent.locks.ReentrantLock; | ||||
| import java.util.stream.Collectors; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.DELETE; | ||||
| import javax.ws.rs.GET; | ||||
| import javax.ws.rs.POST; | ||||
| import javax.ws.rs.Path; | ||||
| import javax.ws.rs.QueryParam; | ||||
| import javax.ws.rs.*; | ||||
| import javax.ws.rs.core.Context; | ||||
| import javax.ws.rs.core.MediaType; | ||||
|  | ||||
| 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.checkerframework.checker.units.qual.A; | ||||
| import org.qortal.account.Account; | ||||
| import org.qortal.account.PrivateKeyAccount; | ||||
| import org.qortal.api.ApiError; | ||||
| import org.qortal.api.ApiErrors; | ||||
| import org.qortal.api.ApiExceptionFactory; | ||||
| import org.qortal.api.Security; | ||||
| import org.qortal.api.*; | ||||
| 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.Controller; | ||||
| import org.qortal.controller.Synchronizer; | ||||
| import org.qortal.controller.Synchronizer.SynchronizationResult; | ||||
| import org.qortal.data.account.MintingAccountData; | ||||
| import org.qortal.data.account.RewardShareData; | ||||
| @@ -67,6 +64,8 @@ import com.google.common.collect.Lists; | ||||
| @Tag(name = "Admin") | ||||
| public class AdminResource { | ||||
|  | ||||
| 	private static final Logger LOGGER = LogManager.getLogger(AdminResource.class); | ||||
|  | ||||
| 	private static final int MAX_LOG_LINES = 500; | ||||
|  | ||||
| 	@Context | ||||
| @@ -76,7 +75,8 @@ public class AdminResource { | ||||
| 	@Path("/unused") | ||||
| 	@Parameter(in = ParameterIn.PATH, name = "assetid", description = "Asset ID, 0 is native coin", schema = @Schema(type = "integer")) | ||||
| 	@Parameter(in = ParameterIn.PATH, name = "otherassetid", description = "Asset ID, 0 is native coin", schema = @Schema(type = "integer")) | ||||
| 	@Parameter(in = ParameterIn.PATH, name = "address", description = "an account address", example = "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v") | ||||
| 	@Parameter(in = ParameterIn.PATH, name = "address", description = "An account address", example = "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v") | ||||
| 	@Parameter(in = ParameterIn.PATH, name = "path", description = "Local path to folder containing the files", schema = @Schema(type = "String", defaultValue = "/Users/user/Documents/MyStaticWebsite")) | ||||
| 	@Parameter(in = ParameterIn.QUERY, name = "count", description = "Maximum number of entries to return, 0 means none", schema = @Schema(type = "integer", defaultValue = "20")) | ||||
| 	@Parameter(in = ParameterIn.QUERY, name = "limit", description = "Maximum number of entries to return, 0 means unlimited", schema = @Schema(type = "integer", defaultValue = "20")) | ||||
| 	@Parameter(in = ParameterIn.QUERY, name = "offset", description = "Starting entry in results, 0 is first entry", schema = @Schema(type = "integer")) | ||||
| @@ -134,10 +134,7 @@ public class AdminResource { | ||||
| 			) | ||||
| 		} | ||||
| 	) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public NodeStatus status() { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		NodeStatus nodeStatus = new NodeStatus(); | ||||
|  | ||||
| 		return nodeStatus; | ||||
| @@ -156,7 +153,7 @@ public class AdminResource { | ||||
| 		} | ||||
| 	) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String shutdown() { | ||||
| 	public String shutdown(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		new Thread(() -> { | ||||
| @@ -185,7 +182,7 @@ public class AdminResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public ActivitySummary summary() { | ||||
| 	public ActivitySummary summary(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		ActivitySummary summary = new ActivitySummary(); | ||||
| @@ -231,7 +228,7 @@ public class AdminResource { | ||||
| 		} | ||||
| 	) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public Controller.StatsSnapshot getEngineStats() { | ||||
| 	public Controller.StatsSnapshot getEngineStats(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		return Controller.getInstance().getStatsSnapshot(); | ||||
| @@ -249,9 +246,7 @@ public class AdminResource { | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public List<MintingAccountData> getMintingAccounts() { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			List<MintingAccountData> mintingAccounts = repository.getAccountRepository().getMintingAccounts(); | ||||
| @@ -297,7 +292,7 @@ public class AdminResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.REPOSITORY_ISSUE, ApiError.CANNOT_MINT}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String addMintingAccount(String seed58) { | ||||
| 	public String addMintingAccount(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String seed58) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| @@ -350,7 +345,7 @@ public class AdminResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String deleteMintingAccount(String key58) { | ||||
| 	public String deleteMintingAccount(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| @@ -450,7 +445,7 @@ public class AdminResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_HEIGHT, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String orphan(String targetHeightString) { | ||||
| 	public String orphan(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String targetHeightString) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try { | ||||
| @@ -459,6 +454,23 @@ public class AdminResource { | ||||
| 			if (targetHeight <= 0 || targetHeight > Controller.getInstance().getChainHeight()) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_HEIGHT); | ||||
|  | ||||
| 			// Make sure we're not orphaning as far back as the archived blocks | ||||
| 			// FUTURE: we could support this by first importing earlier blocks from the archive | ||||
| 			if (Settings.getInstance().isTopOnly() || | ||||
| 				Settings.getInstance().isArchiveEnabled()) { | ||||
|  | ||||
| 				try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 					// Find the first unarchived block | ||||
| 					int oldestBlock = repository.getBlockArchiveRepository().getBlockArchiveHeight(); | ||||
| 					// Add some extra blocks just in case we're currently archiving/pruning | ||||
| 					oldestBlock += 100; | ||||
| 					if (targetHeight <= oldestBlock) { | ||||
| 						LOGGER.info("Unable to orphan beyond block {} because it is archived", oldestBlock); | ||||
| 						throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_HEIGHT); | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			if (BlockChain.orphan(targetHeight)) | ||||
| 				return "true"; | ||||
| 			else | ||||
| @@ -492,7 +504,7 @@ public class AdminResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String forceSync(String targetPeerAddress) { | ||||
| 	public String forceSync(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String targetPeerAddress) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try { | ||||
| @@ -514,7 +526,7 @@ public class AdminResource { | ||||
| 			SynchronizationResult syncResult; | ||||
| 			try { | ||||
| 				do { | ||||
| 					syncResult = Controller.getInstance().actuallySynchronize(targetPeer, true); | ||||
| 					syncResult = Synchronizer.getInstance().actuallySynchronize(targetPeer, true); | ||||
| 				} while (syncResult == SynchronizationResult.OK); | ||||
| 			} finally { | ||||
| 				blockchainLock.unlock(); | ||||
| @@ -538,23 +550,12 @@ public class AdminResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String exportRepository() { | ||||
| 	public String exportRepository(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); | ||||
|  | ||||
| 			blockchainLock.lockInterruptibly(); | ||||
|  | ||||
| 			try { | ||||
| 				repository.exportNodeLocalData(true); | ||||
| 				return "true"; | ||||
| 			} finally { | ||||
| 				blockchainLock.unlock(); | ||||
| 			} | ||||
| 		} catch (InterruptedException e) { | ||||
| 			// We couldn't lock blockchain to perform export | ||||
| 			return "false"; | ||||
| 			repository.exportNodeLocalData(); | ||||
| 			return "true"; | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| @@ -564,13 +565,13 @@ public class AdminResource { | ||||
| 	@Path("/repository/data") | ||||
| 	@Operation( | ||||
| 		summary = "Import data into repository.", | ||||
| 		description = "Imports data from file on local machine. Filename is forced to 'import.script' if apiKey is not set.", | ||||
| 		description = "Imports data from file on local machine. Filename is forced to 'qortal-backup/TradeBotStates.json' if apiKey is not set.", | ||||
| 		requestBody = @RequestBody( | ||||
| 			required = true, | ||||
| 			content = @Content( | ||||
| 				mediaType = MediaType.TEXT_PLAIN, | ||||
| 				schema = @Schema( | ||||
| 					type = "string", example = "MintingAccounts.script" | ||||
| 					type = "string", example = "qortal-backup/TradeBotStates.json" | ||||
| 				) | ||||
| 			) | ||||
| 		), | ||||
| @@ -583,12 +584,12 @@ public class AdminResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String importRepository(String filename) { | ||||
| 	public String importRepository(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String filename) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		// Hard-coded because it's too dangerous to allow user-supplied filenames in weaker security contexts | ||||
| 		if (Settings.getInstance().getApiKey() == null) | ||||
| 			filename = "import.script"; | ||||
| 			filename = "qortal-backup/TradeBotStates.json"; | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); | ||||
| @@ -600,6 +601,10 @@ public class AdminResource { | ||||
| 				repository.saveChanges(); | ||||
|  | ||||
| 				return "true"; | ||||
|  | ||||
| 			} catch (IOException e) { | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e); | ||||
|  | ||||
| 			} finally { | ||||
| 				blockchainLock.unlock(); | ||||
| 			} | ||||
| @@ -625,7 +630,7 @@ public class AdminResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String checkpointRepository() { | ||||
| 	public String checkpointRepository(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		RepositoryManager.setRequestedCheckpoint(Boolean.TRUE); | ||||
| @@ -646,7 +651,7 @@ public class AdminResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String backupRepository() { | ||||
| 	public String backupRepository(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| @@ -655,14 +660,16 @@ public class AdminResource { | ||||
| 			blockchainLock.lockInterruptibly(); | ||||
|  | ||||
| 			try { | ||||
| 				repository.backup(true); | ||||
| 				// Timeout if the database isn't ready for backing up after 60 seconds | ||||
| 				long timeout = 60 * 1000L; | ||||
| 				repository.backup(true, "backup", timeout); | ||||
| 				repository.saveChanges(); | ||||
|  | ||||
| 				return "true"; | ||||
| 			} finally { | ||||
| 				blockchainLock.unlock(); | ||||
| 			} | ||||
| 		} catch (InterruptedException e) { | ||||
| 		} catch (InterruptedException | TimeoutException e) { | ||||
| 			// We couldn't lock blockchain to perform backup | ||||
| 			return "false"; | ||||
| 		} catch (DataException e) { | ||||
| @@ -678,7 +685,7 @@ public class AdminResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public void performRepositoryMaintenance() { | ||||
| 	public void performRepositoryMaintenance(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| @@ -687,15 +694,71 @@ public class AdminResource { | ||||
| 			blockchainLock.lockInterruptibly(); | ||||
|  | ||||
| 			try { | ||||
| 				repository.performPeriodicMaintenance(); | ||||
| 				// Timeout if the database isn't ready to start after 60 seconds | ||||
| 				long timeout = 60 * 1000L; | ||||
| 				repository.performPeriodicMaintenance(timeout); | ||||
| 			} finally { | ||||
| 				blockchainLock.unlock(); | ||||
| 			} | ||||
| 		} catch (InterruptedException e) { | ||||
| 			// No big deal | ||||
| 		} catch (DataException e) { | ||||
| 		} catch (DataException | TimeoutException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/apikey/generate") | ||||
| 	@Operation( | ||||
| 			summary = "Generate an API key", | ||||
| 			description = "This request is unauthenticated if no API key has been generated yet. " + | ||||
| 					"If an API key already exists, it needs to be passed as a header and this endpoint " + | ||||
| 					"will then generate a new key which replaces the existing one.", | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							description = "API key string", | ||||
| 							content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String generateApiKey(@HeaderParam(Security.API_KEY_HEADER) String apiKeyHeader) { | ||||
| 		ApiKey apiKey = Security.getApiKey(request); | ||||
|  | ||||
| 		// If the API key is already generated, we need to authenticate this request | ||||
| 		if (apiKey.generated() && apiKey.exists()) { | ||||
| 			Security.checkApiCallAllowed(request); | ||||
| 		} | ||||
|  | ||||
| 		// Not generated yet - so we are safe to generate one | ||||
| 		// FUTURE: we may want to restrict this to local/loopback only? | ||||
|  | ||||
| 		try { | ||||
| 			apiKey.generate(); | ||||
| 		} catch (IOException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "Unable to generate API key"); | ||||
| 		} | ||||
|  | ||||
| 		return apiKey.toString(); | ||||
| 	} | ||||
|  | ||||
| 	@GET | ||||
| 	@Path("/apikey/test") | ||||
| 	@Operation( | ||||
| 			summary = "Test an API key", | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							description = "true if authenticated", | ||||
| 							content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String testApiKey(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		return "true"; | ||||
| 	} | ||||
|  | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,5 +1,6 @@ | ||||
| package org.qortal.api.resource; | ||||
|  | ||||
| import com.google.common.primitives.Ints; | ||||
| import io.swagger.v3.oas.annotations.Operation; | ||||
| import io.swagger.v3.oas.annotations.Parameter; | ||||
| import io.swagger.v3.oas.annotations.media.ArraySchema; | ||||
| @@ -8,7 +9,14 @@ 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 java.io.ByteArrayOutputStream; | ||||
| import java.io.IOException; | ||||
| import java.math.BigDecimal; | ||||
| import java.math.BigInteger; | ||||
| import java.math.RoundingMode; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.Comparator; | ||||
| import java.util.List; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| @@ -20,18 +28,25 @@ import javax.ws.rs.QueryParam; | ||||
| import javax.ws.rs.core.Context; | ||||
| import javax.ws.rs.core.MediaType; | ||||
|  | ||||
| import org.qortal.account.Account; | ||||
| import org.qortal.api.ApiError; | ||||
| import org.qortal.api.ApiErrors; | ||||
| import org.qortal.api.ApiExceptionFactory; | ||||
| import org.qortal.api.model.BlockMintingInfo; | ||||
| import org.qortal.api.model.BlockSignerSummary; | ||||
| import org.qortal.block.Block; | ||||
| import org.qortal.controller.Controller; | ||||
| import org.qortal.crypto.Crypto; | ||||
| import org.qortal.data.account.AccountData; | ||||
| import org.qortal.data.block.BlockData; | ||||
| import org.qortal.data.block.BlockSummaryData; | ||||
| import org.qortal.data.transaction.TransactionData; | ||||
| import org.qortal.repository.BlockArchiveReader; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.RepositoryManager; | ||||
| import org.qortal.transform.TransformationException; | ||||
| import org.qortal.transform.block.BlockTransformer; | ||||
| import org.qortal.utils.Base58; | ||||
|  | ||||
| @Path("/blocks") | ||||
| @@ -60,7 +75,8 @@ public class BlocksResource { | ||||
| 	@ApiErrors({ | ||||
| 		ApiError.INVALID_SIGNATURE, ApiError.BLOCK_UNKNOWN, ApiError.REPOSITORY_ISSUE | ||||
| 	}) | ||||
| 	public BlockData getBlock(@PathParam("signature") String signature58) { | ||||
| 	public BlockData getBlock(@PathParam("signature") String signature58, | ||||
| 							  @QueryParam("includeOnlineSignatures") Boolean includeOnlineSignatures) { | ||||
| 		// Decode signature | ||||
| 		byte[] signature; | ||||
| 		try { | ||||
| @@ -70,16 +86,80 @@ public class BlocksResource { | ||||
| 		} | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 		    // Check the database first | ||||
| 			BlockData blockData = repository.getBlockRepository().fromSignature(signature); | ||||
| 			if (blockData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
| 			if (blockData != null) { | ||||
| 				if (includeOnlineSignatures == null || includeOnlineSignatures == false) { | ||||
| 					blockData.setOnlineAccountsSignatures(null); | ||||
| 				} | ||||
| 				return blockData; | ||||
| 			} | ||||
|  | ||||
| 			return blockData; | ||||
|             // Not found, so try the block archive | ||||
| 			blockData = repository.getBlockArchiveRepository().fromSignature(signature); | ||||
| 			if (blockData != null) { | ||||
| 				if (includeOnlineSignatures == null || includeOnlineSignatures == false) { | ||||
| 					blockData.setOnlineAccountsSignatures(null); | ||||
| 				} | ||||
| 				return blockData; | ||||
| 			} | ||||
|  | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@GET | ||||
| 	@Path("/signature/{signature}/data") | ||||
| 	@Operation( | ||||
| 			summary = "Fetch serialized, base58 encoded block data using base58 signature", | ||||
| 			description = "Returns serialized data for the block that matches the given signature", | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							description = "the block data", | ||||
| 							content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@ApiErrors({ | ||||
| 			ApiError.INVALID_SIGNATURE, ApiError.BLOCK_UNKNOWN, ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE | ||||
| 	}) | ||||
| 	public String getSerializedBlockData(@PathParam("signature") String signature58) { | ||||
| 		// Decode signature | ||||
| 		byte[] signature; | ||||
| 		try { | ||||
| 			signature = Base58.decode(signature58); | ||||
| 		} catch (NumberFormatException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_SIGNATURE, e); | ||||
| 		} | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|  | ||||
|             // Check the database first | ||||
| 			BlockData blockData = repository.getBlockRepository().fromSignature(signature); | ||||
| 			if (blockData != null) { | ||||
|                 Block block = new Block(repository, blockData); | ||||
|                 ByteArrayOutputStream bytes = new ByteArrayOutputStream(); | ||||
|                 bytes.write(Ints.toByteArray(block.getBlockData().getHeight())); | ||||
|                 bytes.write(BlockTransformer.toBytes(block)); | ||||
|                 return Base58.encode(bytes.toByteArray()); | ||||
|             } | ||||
|  | ||||
|             // Not found, so try the block archive | ||||
|             byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, false, repository); | ||||
|             if (bytes != null) { | ||||
|                 return Base58.encode(bytes); | ||||
|             } | ||||
|  | ||||
|             throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
| 		} catch (TransformationException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA, e); | ||||
| 		} catch (DataException | IOException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@GET | ||||
| 	@Path("/signature/{signature}/transactions") | ||||
| 	@Operation( | ||||
| @@ -117,8 +197,12 @@ public class BlocksResource { | ||||
| 		} | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			if (repository.getBlockRepository().getHeightFromSignature(signature) == 0) | ||||
| 		    // 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); | ||||
|             } | ||||
|  | ||||
| 			return repository.getBlockRepository().getTransactionsFromSignature(signature, limit, offset, reverse); | ||||
| 		} catch (DataException e) { | ||||
| @@ -147,7 +231,19 @@ public class BlocksResource { | ||||
| 	}) | ||||
| 	public BlockData getFirstBlock() { | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			return repository.getBlockRepository().fromHeight(1); | ||||
| 			// Check the database first | ||||
| 			BlockData blockData = repository.getBlockRepository().fromHeight(1); | ||||
| 			if (blockData != null) { | ||||
| 				return blockData; | ||||
| 			} | ||||
|  | ||||
| 			// Try the archive | ||||
| 			blockData = repository.getBlockArchiveRepository().fromHeight(1); | ||||
| 			if (blockData != null) { | ||||
| 				return blockData; | ||||
| 			} | ||||
|  | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| @@ -172,9 +268,15 @@ public class BlocksResource { | ||||
| 	@ApiErrors({ | ||||
| 		ApiError.REPOSITORY_ISSUE | ||||
| 	}) | ||||
| 	public BlockData getLastBlock() { | ||||
| 	public BlockData getLastBlock(@QueryParam("includeOnlineSignatures") Boolean includeOnlineSignatures) { | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			return repository.getBlockRepository().getLastBlock(); | ||||
| 			BlockData blockData = repository.getBlockRepository().getLastBlock(); | ||||
|  | ||||
| 			if (includeOnlineSignatures == null || includeOnlineSignatures == false) { | ||||
| 				blockData.setOnlineAccountsSignatures(null); | ||||
| 			} | ||||
|  | ||||
| 			return blockData; | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| @@ -209,17 +311,28 @@ public class BlocksResource { | ||||
| 		} | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			BlockData childBlockData = null; | ||||
|  | ||||
| 			// Check if block exists in database | ||||
| 			BlockData blockData = repository.getBlockRepository().fromSignature(signature); | ||||
| 			if (blockData != null) { | ||||
| 				return repository.getBlockRepository().fromReference(signature); | ||||
| 			} | ||||
|  | ||||
| 			// Check block exists | ||||
| 			if (blockData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
|  | ||||
| 			BlockData childBlockData = repository.getBlockRepository().fromReference(signature); | ||||
| 			// Not found, so try the archive | ||||
| 			// This also checks that the parent block exists | ||||
| 			// It will return null if either the parent or child don't exit | ||||
| 			childBlockData = repository.getBlockArchiveRepository().fromReference(signature); | ||||
|  | ||||
| 			// Check child block exists | ||||
| 			if (childBlockData == null) | ||||
| 			if (childBlockData == null) { | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
| 			} | ||||
|  | ||||
| 			// Check child block's reference matches the supplied signature | ||||
| 			if (!Arrays.equals(childBlockData.getReference(), signature)) { | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
| 			} | ||||
|  | ||||
| 			return childBlockData; | ||||
| 		} catch (DataException e) { | ||||
| @@ -285,13 +398,20 @@ public class BlocksResource { | ||||
| 		} | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			// Firstly check the database | ||||
| 			BlockData blockData = repository.getBlockRepository().fromSignature(signature); | ||||
| 			if (blockData != null) { | ||||
| 				return blockData.getHeight(); | ||||
| 			} | ||||
|  | ||||
| 			// Check block exists | ||||
| 			if (blockData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
| 			// Not found, so try the archive | ||||
| 			blockData = repository.getBlockArchiveRepository().fromSignature(signature); | ||||
| 			if (blockData != null) { | ||||
| 				return blockData.getHeight(); | ||||
| 			} | ||||
|  | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
|  | ||||
| 			return blockData.getHeight(); | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| @@ -316,13 +436,101 @@ public class BlocksResource { | ||||
| 	@ApiErrors({ | ||||
| 		ApiError.BLOCK_UNKNOWN, ApiError.REPOSITORY_ISSUE | ||||
| 	}) | ||||
| 	public BlockData getByHeight(@PathParam("height") int height) { | ||||
| 	public BlockData getByHeight(@PathParam("height") int height, | ||||
| 								 @QueryParam("includeOnlineSignatures") Boolean includeOnlineSignatures) { | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			// Firstly check the database | ||||
| 			BlockData blockData = repository.getBlockRepository().fromHeight(height); | ||||
| 			if (blockData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
| 			if (blockData != null) { | ||||
| 				if (includeOnlineSignatures == null || includeOnlineSignatures == false) { | ||||
| 					blockData.setOnlineAccountsSignatures(null); | ||||
| 				} | ||||
| 				return blockData; | ||||
| 			} | ||||
|  | ||||
| 			return blockData; | ||||
| 			// Not found, so try the archive | ||||
| 			blockData = repository.getBlockArchiveRepository().fromHeight(height); | ||||
| 			if (blockData != null) { | ||||
| 				if (includeOnlineSignatures == null || includeOnlineSignatures == false) { | ||||
| 					blockData.setOnlineAccountsSignatures(null); | ||||
| 				} | ||||
| 				return blockData; | ||||
| 			} | ||||
|  | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
|  | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@GET | ||||
| 	@Path("/byheight/{height}/mintinginfo") | ||||
| 	@Operation( | ||||
| 			summary = "Fetch block minter info using block height", | ||||
| 			description = "Returns the minter info for the block with given height", | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							description = "the block", | ||||
| 							content = @Content( | ||||
| 									schema = @Schema( | ||||
| 											implementation = BlockData.class | ||||
| 									) | ||||
| 							) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@ApiErrors({ | ||||
| 			ApiError.BLOCK_UNKNOWN, ApiError.REPOSITORY_ISSUE | ||||
| 	}) | ||||
| 	public BlockMintingInfo getBlockMintingInfoByHeight(@PathParam("height") int height) { | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			// Try the database | ||||
| 			BlockData blockData = repository.getBlockRepository().fromHeight(height); | ||||
| 			if (blockData == null) { | ||||
|  | ||||
| 				// Not found, so try the archive | ||||
| 				blockData = repository.getBlockArchiveRepository().fromHeight(height); | ||||
| 				if (blockData == null) { | ||||
|  | ||||
| 					// Still not found | ||||
| 					throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			Block block = new Block(repository, blockData); | ||||
| 			BlockData parentBlockData = repository.getBlockRepository().fromSignature(blockData.getReference()); | ||||
| 			if (parentBlockData == null) { | ||||
| 				// Parent block not found - try the archive | ||||
| 				parentBlockData = repository.getBlockArchiveRepository().fromSignature(blockData.getReference()); | ||||
| 				if (parentBlockData == null) { | ||||
|  | ||||
| 					// Still not found | ||||
| 					throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			int minterLevel = Account.getRewardShareEffectiveMintingLevel(repository, blockData.getMinterPublicKey()); | ||||
| 			if (minterLevel == 0) | ||||
| 				// This may be unavailable when requesting a trimmed block | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); | ||||
|  | ||||
| 			BigInteger distance = block.calcKeyDistance(parentBlockData.getHeight(), parentBlockData.getSignature(), blockData.getMinterPublicKey(), minterLevel); | ||||
| 			double ratio = new BigDecimal(distance).divide(new BigDecimal(block.MAX_DISTANCE), 40, RoundingMode.DOWN).doubleValue(); | ||||
| 			long timestamp = block.calcTimestamp(parentBlockData, blockData.getMinterPublicKey(), minterLevel); | ||||
| 			long timeDelta = timestamp - parentBlockData.getTimestamp(); | ||||
|  | ||||
| 			BlockMintingInfo blockMintingInfo = new BlockMintingInfo(); | ||||
| 			blockMintingInfo.minterPublicKey = blockData.getMinterPublicKey(); | ||||
| 			blockMintingInfo.minterLevel = minterLevel; | ||||
| 			blockMintingInfo.onlineAccountsCount = blockData.getOnlineAccountsCount(); | ||||
| 			blockMintingInfo.maxDistance = new BigDecimal(block.MAX_DISTANCE); | ||||
| 			blockMintingInfo.keyDistance = distance; | ||||
| 			blockMintingInfo.keyDistanceRatio = ratio; | ||||
| 			blockMintingInfo.timestamp = timestamp; | ||||
| 			blockMintingInfo.timeDelta = timeDelta; | ||||
|  | ||||
| 			return blockMintingInfo; | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| @@ -346,15 +554,37 @@ public class BlocksResource { | ||||
| 	@ApiErrors({ | ||||
| 		ApiError.BLOCK_UNKNOWN, ApiError.REPOSITORY_ISSUE | ||||
| 	}) | ||||
| 	public BlockData getByTimestamp(@PathParam("timestamp") long timestamp) { | ||||
| 	public BlockData getByTimestamp(@PathParam("timestamp") long timestamp, | ||||
| 									@QueryParam("includeOnlineSignatures") Boolean includeOnlineSignatures) { | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			int height = repository.getBlockRepository().getHeightFromTimestamp(timestamp); | ||||
| 			if (height == 0) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
| 			BlockData blockData = null; | ||||
|  | ||||
| 			BlockData blockData = repository.getBlockRepository().fromHeight(height); | ||||
| 			if (blockData == null) | ||||
| 			// Try the Blocks table | ||||
| 			int height = repository.getBlockRepository().getHeightFromTimestamp(timestamp); | ||||
| 			if (height > 1) { | ||||
| 				// Found match in Blocks table | ||||
| 				blockData = repository.getBlockRepository().fromHeight(height); | ||||
| 				if (includeOnlineSignatures == null || includeOnlineSignatures == false) { | ||||
| 					blockData.setOnlineAccountsSignatures(null); | ||||
| 				} | ||||
| 				return blockData; | ||||
| 			} | ||||
|  | ||||
| 			// Not found in Blocks table, so try the archive | ||||
| 			height = repository.getBlockArchiveRepository().getHeightFromTimestamp(timestamp); | ||||
| 			if (height > 1) { | ||||
| 				// Found match in archive | ||||
| 				blockData = repository.getBlockArchiveRepository().fromHeight(height); | ||||
| 			} | ||||
|  | ||||
| 			// Ensure block exists | ||||
| 			if (blockData == null) { | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); | ||||
| 			} | ||||
|  | ||||
| 			if (includeOnlineSignatures == null || includeOnlineSignatures == false) { | ||||
| 				blockData.setOnlineAccountsSignatures(null); | ||||
| 			} | ||||
|  | ||||
| 			return blockData; | ||||
| 		} catch (DataException e) { | ||||
| @@ -391,9 +621,14 @@ public class BlocksResource { | ||||
|  | ||||
| 			for (/* count already set */; count > 0; --count, ++height) { | ||||
| 				BlockData blockData = repository.getBlockRepository().fromHeight(height); | ||||
| 				if (blockData == null) | ||||
| 					// Run out of blocks! | ||||
| 					break; | ||||
| 				if (blockData == null) { | ||||
| 					// Not found - try the archive | ||||
| 					blockData = repository.getBlockArchiveRepository().fromHeight(height); | ||||
| 					if (blockData == null) { | ||||
| 						// Run out of blocks! | ||||
| 						break; | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				blocks.add(blockData); | ||||
| 			} | ||||
| @@ -438,7 +673,29 @@ public class BlocksResource { | ||||
| 			if (accountData == null || accountData.getPublicKey() == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.PUBLIC_KEY_NOT_FOUND); | ||||
|  | ||||
| 			return repository.getBlockRepository().getBlockSummariesBySigner(accountData.getPublicKey(), limit, offset, reverse); | ||||
|  | ||||
| 			List<BlockSummaryData> summaries = repository.getBlockRepository() | ||||
| 					.getBlockSummariesBySigner(accountData.getPublicKey(), limit, offset, reverse); | ||||
|  | ||||
| 			// Add any from the archive | ||||
| 			List<BlockSummaryData> archivedSummaries = repository.getBlockArchiveRepository() | ||||
| 					.getBlockSummariesBySigner(accountData.getPublicKey(), limit, offset, reverse); | ||||
| 			if (archivedSummaries != null && !archivedSummaries.isEmpty()) { | ||||
| 				summaries.addAll(archivedSummaries); | ||||
| 			} | ||||
| 			else { | ||||
| 				summaries = archivedSummaries; | ||||
| 			} | ||||
|  | ||||
| 			// Sort the results (because they may have been obtained from two places) | ||||
| 			if (reverse != null && reverse) { | ||||
| 				summaries.sort((s1, s2) -> Integer.valueOf(s2.getHeight()).compareTo(Integer.valueOf(s1.getHeight()))); | ||||
| 			} | ||||
| 			else { | ||||
| 				summaries.sort(Comparator.comparing(s -> Integer.valueOf(s.getHeight()))); | ||||
| 			} | ||||
|  | ||||
| 			return summaries; | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| @@ -474,7 +731,8 @@ public class BlocksResource { | ||||
| 				if (!Crypto.isValidAddress(address)) | ||||
| 					throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); | ||||
|  | ||||
| 			return repository.getBlockRepository().getBlockSigners(addresses, limit, offset, reverse); | ||||
| 			// This method pulls data from both Blocks and BlockArchive, so no need to query serparately | ||||
| 			return repository.getBlockArchiveRepository().getBlockSigners(addresses, limit, offset, reverse); | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| @@ -514,7 +772,76 @@ public class BlocksResource { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			return repository.getBlockRepository().getBlockSummaries(startHeight, endHeight, count); | ||||
|  | ||||
| 			/* | ||||
| 			 * start	end		count		result | ||||
| 			 * 10		40		null		blocks 10 to 39 (excludes end block, ignore count) | ||||
| 			 * | ||||
| 			 * null		null	null		blocks 1 to 50 (assume count=50, maybe start=1) | ||||
| 			 * 30		null	null		blocks 30 to 79 (assume count=50) | ||||
| 			 * 30		null	10			blocks 30 to 39 | ||||
| 			 * | ||||
| 			 * null		null	50			last 50 blocks? so if max(blocks.height) is 200, then blocks 151 to 200 | ||||
| 			 * null		200		null		blocks 150 to 199 (excludes end block, assume count=50) | ||||
| 			 * null		200		10			blocks 190 to 199 (excludes end block) | ||||
| 			 */ | ||||
|  | ||||
| 			List<BlockSummaryData> blockSummaries = new ArrayList<>(); | ||||
|  | ||||
| 			// Use the latest X blocks if only a count is specified | ||||
| 			if (startHeight == null && endHeight == null && count != null) { | ||||
| 				BlockData chainTip = repository.getBlockRepository().getLastBlock(); | ||||
| 				startHeight = chainTip.getHeight() - count; | ||||
| 				endHeight = chainTip.getHeight(); | ||||
| 			} | ||||
|  | ||||
| 			// ... otherwise default the start height to 1 | ||||
| 			if (startHeight == null && endHeight == null) { | ||||
| 				startHeight = 1; | ||||
| 			} | ||||
|  | ||||
| 			// Default the count to 50 | ||||
| 			if (count == null) { | ||||
| 				count = 50; | ||||
| 			} | ||||
|  | ||||
| 			// If both a start and end height exist, ignore the count | ||||
| 			if (startHeight != null && endHeight != null) { | ||||
| 				if (startHeight > 0 && endHeight > 0) { | ||||
| 					count = Integer.MAX_VALUE; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			// Derive start height from end height if missing | ||||
| 			if (startHeight == null || startHeight == 0) { | ||||
| 				if (endHeight != null && endHeight > 0) { | ||||
| 					if (count != null) { | ||||
| 						startHeight = endHeight - count; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			for (/* count already set */; count > 0; --count, ++startHeight) { | ||||
| 				if (endHeight != null && startHeight >= endHeight) { | ||||
| 					break; | ||||
| 				} | ||||
| 				BlockData blockData = repository.getBlockRepository().fromHeight(startHeight); | ||||
| 				if (blockData == null) { | ||||
| 					// Not found - try the archive | ||||
| 					blockData = repository.getBlockArchiveRepository().fromHeight(startHeight); | ||||
| 					if (blockData == null) { | ||||
| 						// Run out of blocks! | ||||
| 						break; | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				if (blockData != null) { | ||||
| 					BlockSummaryData blockSummaryData = new BlockSummaryData(blockData); | ||||
| 					blockSummaries.add(blockSummaryData); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			return blockSummaries; | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
|   | ||||
							
								
								
									
										95
									
								
								src/main/java/org/qortal/api/resource/BootstrapResource.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/main/java/org/qortal/api/resource/BootstrapResource.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | ||||
| 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.security.SecurityRequirement; | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.api.ApiError; | ||||
| import org.qortal.api.ApiExceptionFactory; | ||||
| import org.qortal.api.Security; | ||||
| import org.qortal.repository.Bootstrap; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.RepositoryManager; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.*; | ||||
| import javax.ws.rs.core.Context; | ||||
| import javax.ws.rs.core.MediaType; | ||||
| import java.io.IOException; | ||||
|  | ||||
|  | ||||
| @Path("/bootstrap") | ||||
| @Tag(name = "Bootstrap") | ||||
| public class BootstrapResource { | ||||
|  | ||||
| 	private static final Logger LOGGER = LogManager.getLogger(BootstrapResource.class); | ||||
|  | ||||
| 	@Context | ||||
| 	HttpServletRequest request; | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/create") | ||||
| 	@Operation( | ||||
| 		summary = "Create bootstrap", | ||||
| 		description = "Builds a bootstrap file for distribution", | ||||
| 		responses = { | ||||
| 			@ApiResponse( | ||||
| 				description = "path to file on success, an exception on failure", | ||||
| 				content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) | ||||
| 			) | ||||
| 		} | ||||
| 	) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String createBootstrap(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|  | ||||
| 			Bootstrap bootstrap = new Bootstrap(repository); | ||||
| 			try { | ||||
| 				bootstrap.checkRepositoryState(); | ||||
| 			} catch (DataException e) { | ||||
| 				LOGGER.info("Not ready to create bootstrap: {}", e.getMessage()); | ||||
| 				throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); | ||||
| 			} | ||||
| 			bootstrap.validateBlockchain(); | ||||
| 			return bootstrap.create(); | ||||
|  | ||||
| 		} catch (DataException | InterruptedException | IOException e) { | ||||
| 			LOGGER.info("Unable to create bootstrap", e); | ||||
| 			throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@GET | ||||
| 	@Path("/validate") | ||||
| 	@Operation( | ||||
| 			summary = "Validate blockchain", | ||||
| 			description = "Useful to check database integrity prior to creating or after installing a bootstrap. " + | ||||
| 					"This process is intensive and can take over an hour to run.", | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							description = "true if valid, false if invalid", | ||||
| 							content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public boolean validateBootstrap(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|  | ||||
| 			Bootstrap bootstrap = new Bootstrap(repository); | ||||
| 			return bootstrap.validateCompleteBlockchain(); | ||||
|  | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -13,11 +13,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; | ||||
| import java.util.List; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.GET; | ||||
| import javax.ws.rs.POST; | ||||
| import javax.ws.rs.Path; | ||||
| import javax.ws.rs.PathParam; | ||||
| import javax.ws.rs.QueryParam; | ||||
| import javax.ws.rs.*; | ||||
| import javax.ws.rs.core.Context; | ||||
| import javax.ws.rs.core.MediaType; | ||||
|  | ||||
| @@ -158,7 +154,7 @@ public class ChatResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String buildChat(ChatTransactionData transactionData) { | ||||
| 	public String buildChat(@HeaderParam(Security.API_KEY_HEADER) String apiKey, ChatTransactionData transactionData) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| @@ -206,7 +202,7 @@ public class ChatResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String buildChat(String rawBytes58) { | ||||
| 	public String buildChat(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String rawBytes58) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|   | ||||
| @@ -5,12 +5,14 @@ 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.security.SecurityRequirement; | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
|  | ||||
| import java.util.Arrays; | ||||
| import java.util.Random; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.HeaderParam; | ||||
| import javax.ws.rs.POST; | ||||
| import javax.ws.rs.Path; | ||||
| import javax.ws.rs.core.Context; | ||||
| @@ -22,7 +24,7 @@ import org.qortal.api.ApiErrors; | ||||
| import org.qortal.api.ApiExceptionFactory; | ||||
| import org.qortal.api.Security; | ||||
| import org.qortal.api.model.CrossChainBuildRequest; | ||||
| import org.qortal.api.model.CrossChainSecretRequest; | ||||
| import org.qortal.api.model.CrossChainDualSecretRequest; | ||||
| import org.qortal.api.model.CrossChainTradeRequest; | ||||
| import org.qortal.asset.Asset; | ||||
| import org.qortal.crosschain.BitcoinACCTv1; | ||||
| @@ -79,7 +81,8 @@ public class CrossChainBitcoinACCTv1Resource { | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_DATA, ApiError.INVALID_REFERENCE, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) | ||||
| 	public String buildTrade(CrossChainBuildRequest tradeRequest) { | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String buildTrade(@HeaderParam(Security.API_KEY_HEADER) String apiKey, CrossChainBuildRequest tradeRequest) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		byte[] creatorPublicKey = tradeRequest.creatorPublicKey; | ||||
| @@ -174,7 +177,8 @@ public class CrossChainBitcoinACCTv1Resource { | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) | ||||
| 	public String buildTradeMessage(CrossChainTradeRequest tradeRequest) { | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String buildTradeMessage(@HeaderParam(Security.API_KEY_HEADER) String apiKey, CrossChainTradeRequest tradeRequest) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		byte[] tradePublicKey = tradeRequest.tradePublicKey; | ||||
| @@ -242,7 +246,7 @@ public class CrossChainBitcoinACCTv1Resource { | ||||
| 			content = @Content( | ||||
| 				mediaType = MediaType.APPLICATION_JSON, | ||||
| 				schema = @Schema( | ||||
| 					implementation = CrossChainSecretRequest.class | ||||
| 					implementation = CrossChainDualSecretRequest.class | ||||
| 				) | ||||
| 			) | ||||
| 		), | ||||
| @@ -257,7 +261,8 @@ public class CrossChainBitcoinACCTv1Resource { | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) | ||||
| 	public String buildRedeemMessage(CrossChainSecretRequest secretRequest) { | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String buildRedeemMessage(@HeaderParam(Security.API_KEY_HEADER) String apiKey, CrossChainDualSecretRequest secretRequest) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		byte[] partnerPublicKey = secretRequest.partnerPublicKey; | ||||
| @@ -360,4 +365,4 @@ public class CrossChainBitcoinACCTv1Resource { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -6,11 +6,13 @@ 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.security.SecurityRequirement; | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
|  | ||||
| import java.util.List; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.HeaderParam; | ||||
| import javax.ws.rs.POST; | ||||
| import javax.ws.rs.Path; | ||||
| import javax.ws.rs.core.Context; | ||||
| @@ -23,8 +25,8 @@ import org.qortal.api.ApiExceptionFactory; | ||||
| import org.qortal.api.Security; | ||||
| import org.qortal.api.model.crosschain.BitcoinSendRequest; | ||||
| import org.qortal.crosschain.Bitcoin; | ||||
| import org.qortal.crosschain.BitcoinyTransaction; | ||||
| import org.qortal.crosschain.ForeignBlockchainException; | ||||
| import org.qortal.crosschain.SimpleTransaction; | ||||
|  | ||||
| @Path("/crosschain/btc") | ||||
| @Tag(name = "Cross-Chain (Bitcoin)") | ||||
| @@ -56,7 +58,8 @@ public class CrossChainBitcoinResource { | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) | ||||
| 	public String getBitcoinWalletBalance(String key58) { | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String getBitcoinWalletBalance(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		Bitcoin bitcoin = Bitcoin.getInstance(); | ||||
| @@ -64,11 +67,16 @@ public class CrossChainBitcoinResource { | ||||
| 		if (!bitcoin.isValidDeterministicKey(key58)) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); | ||||
|  | ||||
| 		Long balance = bitcoin.getWalletBalance(key58); | ||||
| 		if (balance == null) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); | ||||
| 		try { | ||||
| 			Long balance = bitcoin.getWalletBalanceFromTransactions(key58); | ||||
| 			if (balance == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); | ||||
|  | ||||
| 		return balance.toString(); | ||||
| 			return balance.toString(); | ||||
|  | ||||
| 		} catch (ForeignBlockchainException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@POST | ||||
| @@ -89,12 +97,13 @@ public class CrossChainBitcoinResource { | ||||
| 		), | ||||
| 		responses = { | ||||
| 			@ApiResponse( | ||||
| 				content = @Content(array = @ArraySchema( schema = @Schema( implementation = BitcoinyTransaction.class ) ) ) | ||||
| 				content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) ) | ||||
| 			) | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) | ||||
| 	public List<BitcoinyTransaction> getBitcoinWalletTransactions(String key58) { | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public List<SimpleTransaction> getBitcoinWalletTransactions(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		Bitcoin bitcoin = Bitcoin.getInstance(); | ||||
| @@ -130,7 +139,8 @@ public class CrossChainBitcoinResource { | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) | ||||
| 	public String sendBitcoin(BitcoinSendRequest bitcoinSendRequest) { | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String sendBitcoin(@HeaderParam(Security.API_KEY_HEADER) String apiKey, BitcoinSendRequest bitcoinSendRequest) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		if (bitcoinSendRequest.bitcoinAmount <= 0) | ||||
| @@ -164,4 +174,4 @@ public class CrossChainBitcoinResource { | ||||
| 		return spendTransaction.getTxId().toString(); | ||||
| 	} | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,143 @@ | ||||
| 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.security.SecurityRequirement; | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
| import org.qortal.account.PrivateKeyAccount; | ||||
| import org.qortal.api.ApiError; | ||||
| import org.qortal.api.ApiErrors; | ||||
| import org.qortal.api.ApiExceptionFactory; | ||||
| import org.qortal.api.Security; | ||||
| import org.qortal.api.model.CrossChainSecretRequest; | ||||
| import org.qortal.crosschain.AcctMode; | ||||
| import org.qortal.crosschain.DogecoinACCTv1; | ||||
| import org.qortal.crypto.Crypto; | ||||
| import org.qortal.data.at.ATData; | ||||
| import org.qortal.data.crosschain.CrossChainTradeData; | ||||
| import org.qortal.group.Group; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.RepositoryManager; | ||||
| import org.qortal.transaction.MessageTransaction; | ||||
| import org.qortal.transaction.Transaction.ValidationResult; | ||||
| import org.qortal.transform.Transformer; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.HeaderParam; | ||||
| import javax.ws.rs.POST; | ||||
| import javax.ws.rs.Path; | ||||
| import javax.ws.rs.core.Context; | ||||
| import javax.ws.rs.core.MediaType; | ||||
| import java.util.Arrays; | ||||
|  | ||||
| @Path("/crosschain/DogecoinACCTv1") | ||||
| @Tag(name = "Cross-Chain (DogecoinACCTv1)") | ||||
| public class CrossChainDogecoinACCTv1Resource { | ||||
|  | ||||
| 	@Context | ||||
| 	HttpServletRequest request; | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/redeemmessage") | ||||
| 	@Operation( | ||||
| 		summary = "Signs and broadcasts a 'redeem' MESSAGE transaction that sends secrets to AT, releasing funds to partner", | ||||
| 		description = "Specify address of cross-chain AT that needs to be messaged, Alice's trade private key, the 32-byte secret,<br>" | ||||
| 			+ "and an address for receiving QORT from AT. All of these can be found in Alice's trade bot data.<br>" | ||||
| 			+ "AT needs to be in 'trade' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!<br>" | ||||
| 			+ "You need to use the private key that the AT considers the trade 'partner' otherwise the MESSAGE transaction will be invalid.", | ||||
| 		requestBody = @RequestBody( | ||||
| 			required = true, | ||||
| 			content = @Content( | ||||
| 				mediaType = MediaType.APPLICATION_JSON, | ||||
| 				schema = @Schema( | ||||
| 					implementation = CrossChainSecretRequest.class | ||||
| 				) | ||||
| 			) | ||||
| 		), | ||||
| 		responses = { | ||||
| 			@ApiResponse( | ||||
| 				content = @Content( | ||||
| 					schema = @Schema( | ||||
| 						type = "string" | ||||
| 					) | ||||
| 				) | ||||
| 			) | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public boolean buildRedeemMessage(@HeaderParam(Security.API_KEY_HEADER) String apiKey, CrossChainSecretRequest secretRequest) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		byte[] partnerPrivateKey = secretRequest.partnerPrivateKey; | ||||
|  | ||||
| 		if (partnerPrivateKey == null || partnerPrivateKey.length != Transformer.PRIVATE_KEY_LENGTH) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); | ||||
|  | ||||
| 		if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress)) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); | ||||
|  | ||||
| 		if (secretRequest.secret == null || secretRequest.secret.length != DogecoinACCTv1.SECRET_LENGTH) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); | ||||
|  | ||||
| 		if (secretRequest.receivingAddress == null || !Crypto.isValidAddress(secretRequest.receivingAddress)) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress); | ||||
| 			CrossChainTradeData crossChainTradeData = DogecoinACCTv1.getInstance().populateTradeData(repository, atData); | ||||
|  | ||||
| 			if (crossChainTradeData.mode != AcctMode.TRADING) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			byte[] partnerPublicKey = new PrivateKeyAccount(null, partnerPrivateKey).getPublicKey(); | ||||
| 			String partnerAddress = Crypto.toAddress(partnerPublicKey); | ||||
|  | ||||
| 			// MESSAGE must come from address that AT considers trade partner | ||||
| 			if (!crossChainTradeData.qortalPartnerAddress.equals(partnerAddress)) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); | ||||
|  | ||||
| 			// Good to make MESSAGE | ||||
|  | ||||
| 			byte[] messageData = DogecoinACCTv1.buildRedeemMessage(secretRequest.secret, secretRequest.receivingAddress); | ||||
|  | ||||
| 			PrivateKeyAccount sender = new PrivateKeyAccount(repository, partnerPrivateKey); | ||||
| 			MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, secretRequest.atAddress, messageData, false, false); | ||||
|  | ||||
| 			messageTransaction.computeNonce(); | ||||
| 			messageTransaction.sign(sender); | ||||
|  | ||||
| 			// reset repository state to prevent deadlock | ||||
| 			repository.discardChanges(); | ||||
| 			ValidationResult result = messageTransaction.importAsUnconfirmed(); | ||||
|  | ||||
| 			if (result != ValidationResult.OK) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); | ||||
|  | ||||
| 			return true; | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private ATData fetchAtDataWithChecking(Repository repository, String atAddress) throws DataException { | ||||
| 		ATData atData = repository.getATRepository().fromATAddress(atAddress); | ||||
| 		if (atData == null) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); | ||||
|  | ||||
| 		// Must be correct AT - check functionality using code hash | ||||
| 		if (!Arrays.equals(atData.getCodeHash(), DogecoinACCTv1.CODE_BYTES_HASH)) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 		// No point sending message to AT that's finished | ||||
| 		if (atData.getIsFinished()) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 		return atData; | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,175 @@ | ||||
| package org.qortal.api.resource; | ||||
|  | ||||
| import io.swagger.v3.oas.annotations.Operation; | ||||
| import io.swagger.v3.oas.annotations.media.ArraySchema; | ||||
| 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.security.SecurityRequirement; | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
| import org.bitcoinj.core.Transaction; | ||||
| import org.qortal.api.ApiError; | ||||
| import org.qortal.api.ApiErrors; | ||||
| import org.qortal.api.ApiExceptionFactory; | ||||
| import org.qortal.api.Security; | ||||
| import org.qortal.api.model.crosschain.DogecoinSendRequest; | ||||
| import org.qortal.crosschain.ForeignBlockchainException; | ||||
| import org.qortal.crosschain.Dogecoin; | ||||
| import org.qortal.crosschain.SimpleTransaction; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.HeaderParam; | ||||
| import javax.ws.rs.POST; | ||||
| import javax.ws.rs.Path; | ||||
| import javax.ws.rs.core.Context; | ||||
| import javax.ws.rs.core.MediaType; | ||||
| import java.util.List; | ||||
|  | ||||
| @Path("/crosschain/doge") | ||||
| @Tag(name = "Cross-Chain (Dogecoin)") | ||||
| public class CrossChainDogecoinResource { | ||||
|  | ||||
| 	@Context | ||||
| 	HttpServletRequest request; | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/walletbalance") | ||||
| 	@Operation( | ||||
| 		summary = "Returns DOGE balance for hierarchical, deterministic BIP32 wallet", | ||||
| 		description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", | ||||
| 		requestBody = @RequestBody( | ||||
| 			required = true, | ||||
| 			content = @Content( | ||||
| 				mediaType = MediaType.TEXT_PLAIN, | ||||
| 				schema = @Schema( | ||||
| 					type = "string", | ||||
| 					description = "BIP32 'm' private/public key in base58", | ||||
| 					example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" | ||||
| 				) | ||||
| 			) | ||||
| 		), | ||||
| 		responses = { | ||||
| 			@ApiResponse( | ||||
| 				content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "balance (satoshis)")) | ||||
| 			) | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String getDogecoinWalletBalance(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		Dogecoin dogecoin = Dogecoin.getInstance(); | ||||
|  | ||||
| 		if (!dogecoin.isValidDeterministicKey(key58)) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); | ||||
|  | ||||
| 		try { | ||||
| 			Long balance = dogecoin.getWalletBalanceFromTransactions(key58); | ||||
| 			if (balance == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); | ||||
|  | ||||
| 			return balance.toString(); | ||||
|  | ||||
| 		} catch (ForeignBlockchainException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/wallettransactions") | ||||
| 	@Operation( | ||||
| 		summary = "Returns transactions for hierarchical, deterministic BIP32 wallet", | ||||
| 		description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", | ||||
| 		requestBody = @RequestBody( | ||||
| 			required = true, | ||||
| 			content = @Content( | ||||
| 				mediaType = MediaType.TEXT_PLAIN, | ||||
| 				schema = @Schema( | ||||
| 					type = "string", | ||||
| 					description = "BIP32 'm' private/public key in base58", | ||||
| 					example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" | ||||
| 				) | ||||
| 			) | ||||
| 		), | ||||
| 		responses = { | ||||
| 			@ApiResponse( | ||||
| 				content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) ) | ||||
| 			) | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public List<SimpleTransaction> getDogecoinWalletTransactions(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		Dogecoin dogecoin = Dogecoin.getInstance(); | ||||
|  | ||||
| 		if (!dogecoin.isValidDeterministicKey(key58)) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); | ||||
|  | ||||
| 		try { | ||||
| 			return dogecoin.getWalletTransactions(key58); | ||||
| 		} catch (ForeignBlockchainException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/send") | ||||
| 	@Operation( | ||||
| 		summary = "Sends DOGE from hierarchical, deterministic BIP32 wallet to specific address", | ||||
| 		description = "Currently only supports 'legacy' P2PKH Dogecoin addresses. Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", | ||||
| 		requestBody = @RequestBody( | ||||
| 			required = true, | ||||
| 			content = @Content( | ||||
| 				mediaType = MediaType.APPLICATION_JSON, | ||||
| 				schema = @Schema( | ||||
| 					implementation = DogecoinSendRequest.class | ||||
| 				) | ||||
| 			) | ||||
| 		), | ||||
| 		responses = { | ||||
| 			@ApiResponse( | ||||
| 				content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "transaction hash")) | ||||
| 			) | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String sendBitcoin(@HeaderParam(Security.API_KEY_HEADER) String apiKey, DogecoinSendRequest dogecoinSendRequest) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		if (dogecoinSendRequest.dogecoinAmount <= 0) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 		if (dogecoinSendRequest.feePerByte != null && dogecoinSendRequest.feePerByte <= 0) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 		Dogecoin dogecoin = Dogecoin.getInstance(); | ||||
|  | ||||
| 		if (!dogecoin.isValidAddress(dogecoinSendRequest.receivingAddress)) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); | ||||
|  | ||||
| 		if (!dogecoin.isValidDeterministicKey(dogecoinSendRequest.xprv58)) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); | ||||
|  | ||||
| 		Transaction spendTransaction = dogecoin.buildSpend(dogecoinSendRequest.xprv58, | ||||
| 				dogecoinSendRequest.receivingAddress, | ||||
| 				dogecoinSendRequest.dogecoinAmount, | ||||
| 				dogecoinSendRequest.feePerByte); | ||||
|  | ||||
| 		if (spendTransaction == null) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE); | ||||
|  | ||||
| 		try { | ||||
| 			dogecoin.broadcastTransaction(spendTransaction); | ||||
| 		} catch (ForeignBlockchainException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); | ||||
| 		} | ||||
|  | ||||
| 		return spendTransaction.getTxId().toString(); | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @@ -4,36 +4,40 @@ 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.security.SecurityRequirement; | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
|  | ||||
| import java.math.BigDecimal; | ||||
| import java.util.List; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.GET; | ||||
| import javax.ws.rs.Path; | ||||
| import javax.ws.rs.PathParam; | ||||
| import javax.ws.rs.*; | ||||
| import javax.ws.rs.core.Context; | ||||
| import javax.ws.rs.core.MediaType; | ||||
|  | ||||
| import org.bitcoinj.core.TransactionOutput; | ||||
| import org.qortal.api.ApiError; | ||||
| import org.qortal.api.ApiErrors; | ||||
| import org.qortal.api.ApiExceptionFactory; | ||||
| import org.qortal.api.Security; | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.bitcoinj.core.*; | ||||
| import org.bitcoinj.script.Script; | ||||
| import org.qortal.api.*; | ||||
| import org.qortal.api.model.CrossChainBitcoinyHTLCStatus; | ||||
| import org.qortal.crosschain.Bitcoiny; | ||||
| import org.qortal.crosschain.ForeignBlockchainException; | ||||
| import org.qortal.crosschain.SupportedBlockchain; | ||||
| import org.qortal.crosschain.BitcoinyHTLC; | ||||
| import org.qortal.crosschain.*; | ||||
| import org.qortal.crypto.Crypto; | ||||
| import org.qortal.data.at.ATData; | ||||
| import org.qortal.data.crosschain.CrossChainTradeData; | ||||
| import org.qortal.data.crosschain.TradeBotData; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.RepositoryManager; | ||||
| import org.qortal.utils.Base58; | ||||
| import org.qortal.utils.NTP; | ||||
|  | ||||
| import com.google.common.hash.HashCode; | ||||
|  | ||||
| @Path("/crosschain/htlc") | ||||
| @Tag(name = "Cross-Chain (Hash time-locked contracts)") | ||||
| public class CrossChainHtlcResource { | ||||
|  | ||||
| 	private static final Logger LOGGER = LogManager.getLogger(CrossChainHtlcResource.class); | ||||
|  | ||||
| 	@Context | ||||
| 	HttpServletRequest request; | ||||
|  | ||||
| @@ -41,7 +45,7 @@ public class CrossChainHtlcResource { | ||||
| 	@Path("/address/{blockchain}/{refundPKH}/{locktime}/{redeemPKH}/{hashOfSecret}") | ||||
| 	@Operation( | ||||
| 		summary = "Returns HTLC address based on trade info", | ||||
| 		description = "Blockchain can be BITCOIN or LITECOIN. Public key hashes (PKH) and hash of secret should be 20 bytes (hex). Locktime is seconds since epoch.", | ||||
| 		description = "Public key hashes (PKH) and hash of secret should be 20 bytes (base58 encoded). Locktime is seconds since epoch.", | ||||
| 		responses = { | ||||
| 			@ApiResponse( | ||||
| 				content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) | ||||
| @@ -50,21 +54,21 @@ public class CrossChainHtlcResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_CRITERIA}) | ||||
| 	public String deriveHtlcAddress(@PathParam("blockchain") String blockchainName, | ||||
| 			@PathParam("refundPKH") String refundHex, | ||||
| 			@PathParam("refundPKH") String refundPKH, | ||||
| 			@PathParam("locktime") int lockTime, | ||||
| 			@PathParam("redeemPKH") String redeemHex, | ||||
| 			@PathParam("hashOfSecret") String hashOfSecretHex) { | ||||
| 			@PathParam("redeemPKH") String redeemPKH, | ||||
| 			@PathParam("hashOfSecret") String hashOfSecret) { | ||||
| 		SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName); | ||||
| 		if (blockchain == null) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 		byte[] refunderPubKeyHash; | ||||
| 		byte[] redeemerPubKeyHash; | ||||
| 		byte[] hashOfSecret; | ||||
| 		byte[] decodedHashOfSecret; | ||||
|  | ||||
| 		try { | ||||
| 			refunderPubKeyHash = HashCode.fromString(refundHex).asBytes(); | ||||
| 			redeemerPubKeyHash = HashCode.fromString(redeemHex).asBytes(); | ||||
| 			refunderPubKeyHash = Base58.decode(refundPKH); | ||||
| 			redeemerPubKeyHash = Base58.decode(redeemPKH); | ||||
|  | ||||
| 			if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); | ||||
| @@ -73,14 +77,14 @@ public class CrossChainHtlcResource { | ||||
| 		} | ||||
|  | ||||
| 		try { | ||||
| 			hashOfSecret = HashCode.fromString(hashOfSecretHex).asBytes(); | ||||
| 			if (hashOfSecret.length != 20) | ||||
| 			decodedHashOfSecret = Base58.decode(hashOfSecret); | ||||
| 			if (decodedHashOfSecret.length != 20) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
| 		} catch (IllegalArgumentException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
| 		} | ||||
|  | ||||
| 		byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, hashOfSecret); | ||||
| 		byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, decodedHashOfSecret); | ||||
|  | ||||
| 		Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance(); | ||||
|  | ||||
| @@ -91,7 +95,7 @@ public class CrossChainHtlcResource { | ||||
| 	@Path("/status/{blockchain}/{refundPKH}/{locktime}/{redeemPKH}/{hashOfSecret}") | ||||
| 	@Operation( | ||||
| 		summary = "Checks HTLC status", | ||||
| 		description = "Blockchain can be BITCOIN or LITECOIN. Public key hashes (PKH) and hash of secret should be 20 bytes (hex). Locktime is seconds since epoch.", | ||||
| 		description = "Public key hashes (PKH) and hash of secret should be 20 bytes (base58 encoded). Locktime is seconds since epoch.", | ||||
| 		responses = { | ||||
| 			@ApiResponse( | ||||
| 				content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinyHTLCStatus.class)) | ||||
| @@ -99,11 +103,13 @@ public class CrossChainHtlcResource { | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) | ||||
| 	public CrossChainBitcoinyHTLCStatus checkHtlcStatus(@PathParam("blockchain") String blockchainName, | ||||
| 			@PathParam("refundPKH") String refundHex, | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public CrossChainBitcoinyHTLCStatus checkHtlcStatus(@HeaderParam(Security.API_KEY_HEADER) String apiKey, | ||||
| 			@PathParam("blockchain") String blockchainName, | ||||
| 			@PathParam("refundPKH") String refundPKH, | ||||
| 			@PathParam("locktime") int lockTime, | ||||
| 			@PathParam("redeemPKH") String redeemHex, | ||||
| 			@PathParam("hashOfSecret") String hashOfSecretHex) { | ||||
| 			@PathParam("redeemPKH") String redeemPKH, | ||||
| 			@PathParam("hashOfSecret") String hashOfSecret) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName); | ||||
| @@ -112,11 +118,11 @@ public class CrossChainHtlcResource { | ||||
|  | ||||
| 		byte[] refunderPubKeyHash; | ||||
| 		byte[] redeemerPubKeyHash; | ||||
| 		byte[] hashOfSecret; | ||||
| 		byte[] decodedHashOfSecret; | ||||
|  | ||||
| 		try { | ||||
| 			refunderPubKeyHash = HashCode.fromString(refundHex).asBytes(); | ||||
| 			redeemerPubKeyHash = HashCode.fromString(redeemHex).asBytes(); | ||||
| 			refunderPubKeyHash = Base58.decode(refundPKH); | ||||
| 			redeemerPubKeyHash = Base58.decode(redeemPKH); | ||||
|  | ||||
| 			if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); | ||||
| @@ -125,14 +131,14 @@ public class CrossChainHtlcResource { | ||||
| 		} | ||||
|  | ||||
| 		try { | ||||
| 			hashOfSecret = HashCode.fromString(hashOfSecretHex).asBytes(); | ||||
| 			if (hashOfSecret.length != 20) | ||||
| 			decodedHashOfSecret = Base58.decode(hashOfSecret); | ||||
| 			if (decodedHashOfSecret.length != 20) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
| 		} catch (IllegalArgumentException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
| 		} | ||||
|  | ||||
| 		byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, hashOfSecret); | ||||
| 		byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, decodedHashOfSecret); | ||||
|  | ||||
| 		Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance(); | ||||
|  | ||||
| @@ -168,8 +174,484 @@ public class CrossChainHtlcResource { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// TODO: refund | ||||
| 	@POST | ||||
| 	@Path("/redeem/{ataddress}") | ||||
| 	@Operation( | ||||
| 			summary = "Redeems HTLC associated with supplied AT", | ||||
| 			description = "To be used by a QORT seller (Bob) who needs to redeem LTC/DOGE/etc proceeds that are stuck in a P2SH.<br>" + | ||||
| 					"This requires Bob's trade bot data to be present in the database for this AT.<br>" + | ||||
| 					"It will fail if the buyer has yet to redeem the QORT held in the AT.", | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public boolean redeemHtlc(@HeaderParam(Security.API_KEY_HEADER) String apiKey, @PathParam("ataddress") String atAddress) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 	// TODO: redeem | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			ATData atData = repository.getATRepository().fromATAddress(atAddress); | ||||
| 			if (atData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); | ||||
|  | ||||
| } | ||||
| 			ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); | ||||
| 			if (acct == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); | ||||
| 			if (crossChainTradeData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			// Attempt to find secret from the buyer's message to AT | ||||
| 			byte[] decodedSecret = acct.findSecretA(repository, crossChainTradeData); | ||||
| 			if (decodedSecret == null) { | ||||
| 				LOGGER.info(() -> String.format("Unable to find secret-A from redeem message to AT %s", atAddress)); | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
| 			} | ||||
|  | ||||
| 			List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); | ||||
| 			TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null); | ||||
|  | ||||
| 			// Search for the tradePrivateKey in the tradebot data | ||||
| 			byte[] decodedPrivateKey = null; | ||||
| 			if (tradeBotData != null) | ||||
| 				decodedPrivateKey = tradeBotData.getTradePrivateKey(); | ||||
|  | ||||
| 			// Search for the foreign blockchain receiving address in the tradebot data | ||||
| 			byte[] foreignBlockchainReceivingAccountInfo = null; | ||||
| 			if (tradeBotData != null) | ||||
| 				// Use receiving address PKH from tradebot data | ||||
| 				foreignBlockchainReceivingAccountInfo = tradeBotData.getReceivingAccountInfo(); | ||||
|  | ||||
| 			return this.doRedeemHtlc(atAddress, decodedPrivateKey, decodedSecret, foreignBlockchainReceivingAccountInfo); | ||||
|  | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/redeemAll") | ||||
| 	@Operation( | ||||
| 			summary = "Redeems HTLC for all applicable ATs in tradebot data", | ||||
| 			description = "To be used by a QORT seller (Bob) who needs to redeem LTC/DOGE/etc proceeds that are stuck in P2SH transactions.<br>" + | ||||
| 					"This requires Bob's trade bot data to be present in the database for any ATs that need redeeming.<br>" + | ||||
| 					"Returns true if at least one trade is redeemed. More detail is available in the log.txt.* file.", | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public boolean redeemAllHtlc(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
| 		boolean success = false; | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); | ||||
|  | ||||
| 			for (TradeBotData tradeBotData : allTradeBotData) { | ||||
| 				String atAddress = tradeBotData.getAtAddress(); | ||||
| 				if (atAddress == null) { | ||||
| 					LOGGER.info("Missing AT address in tradebot data", atAddress); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				String tradeState = tradeBotData.getState(); | ||||
| 				if (tradeState == null) { | ||||
| 					LOGGER.info("Missing trade state for AT {}", atAddress); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				if (tradeState.startsWith("ALICE")) { | ||||
| 					LOGGER.info("AT {} isn't redeemable because it is a buy order", atAddress); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				ATData atData = repository.getATRepository().fromATAddress(atAddress); | ||||
| 				if (atData == null) { | ||||
| 					LOGGER.info("Couldn't find AT with address {}", atAddress); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); | ||||
| 				if (acct == null) { | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); | ||||
| 				if (crossChainTradeData == null) { | ||||
| 					LOGGER.info("Couldn't find crosschain trade data for AT {}", atAddress); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				// Attempt to find secret from the buyer's message to AT | ||||
| 				byte[] decodedSecret = acct.findSecretA(repository, crossChainTradeData); | ||||
| 				if (decodedSecret == null) { | ||||
| 					LOGGER.info("Unable to find secret-A from redeem message to AT {}", atAddress); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				// Search for the tradePrivateKey in the tradebot data | ||||
| 				byte[] decodedPrivateKey = tradeBotData.getTradePrivateKey(); | ||||
|  | ||||
| 				// Search for the foreign blockchain receiving address PKH in the tradebot data | ||||
| 				byte[] foreignBlockchainReceivingAccountInfo = tradeBotData.getReceivingAccountInfo(); | ||||
|  | ||||
| 				try { | ||||
| 					LOGGER.info("Attempting to redeem P2SH balance associated with AT {}...", atAddress); | ||||
| 					boolean redeemed = this.doRedeemHtlc(atAddress, decodedPrivateKey, decodedSecret, foreignBlockchainReceivingAccountInfo); | ||||
| 					if (redeemed) { | ||||
| 						LOGGER.info("Redeemed P2SH balance associated with AT {}", atAddress); | ||||
| 						success = true; | ||||
| 					} | ||||
| 					else { | ||||
| 						LOGGER.info("Couldn't redeem P2SH balance associated with AT {}. Already redeemed?", atAddress); | ||||
| 					} | ||||
| 				} catch (ApiException e) { | ||||
| 					LOGGER.info("Couldn't redeem P2SH balance associated with AT {}. Missing data?", atAddress); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
|  | ||||
| 		return success; | ||||
| 	} | ||||
|  | ||||
| 	private boolean doRedeemHtlc(String atAddress, byte[] decodedTradePrivateKey, byte[] decodedSecret, | ||||
| 								 byte[] foreignBlockchainReceivingAccountInfo) { | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|  | ||||
| 			ATData atData = repository.getATRepository().fromATAddress(atAddress); | ||||
| 			if (atData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); | ||||
|  | ||||
| 			ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); | ||||
| 			if (acct == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); | ||||
| 			if (crossChainTradeData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			// Validate trade private key | ||||
| 			if (decodedTradePrivateKey == null || decodedTradePrivateKey.length != 32) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			// Validate secret | ||||
| 			if (decodedSecret == null || decodedSecret.length != 32) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			// Validate receiving address | ||||
| 			if (foreignBlockchainReceivingAccountInfo == null || foreignBlockchainReceivingAccountInfo.length != 20) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			// Make sure the receiving address isn't a QORT address, given that we can share the same field for both QORT and foreign blockchains | ||||
| 			if (Crypto.isValidAddress(foreignBlockchainReceivingAccountInfo)) | ||||
| 				if (Base58.encode(foreignBlockchainReceivingAccountInfo).startsWith("Q")) | ||||
| 					// This is likely a QORT address, not a foreign blockchain | ||||
| 					throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
|  | ||||
| 			// Use secret-A to redeem P2SH-A | ||||
|  | ||||
| 			Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain(); | ||||
| 			if (bitcoiny.getClass() == Bitcoin.class) { | ||||
| 				LOGGER.info("Redeeming a Bitcoin HTLC is not yet supported"); | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
| 			} | ||||
|  | ||||
| 			int lockTime = crossChainTradeData.lockTimeA; | ||||
| 			byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTime, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA); | ||||
| 			String p2shAddressA = bitcoiny.deriveP2shAddress(redeemScriptA); | ||||
| 			LOGGER.info(String.format("Redeeming P2SH address: %s", p2shAddressA)); | ||||
|  | ||||
| 			// Fee for redeem/refund is subtracted from P2SH-A balance. | ||||
| 			long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout); | ||||
| 			long p2shFee = bitcoiny.getP2shFee(feeTimestamp); | ||||
| 			long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; | ||||
| 			BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA); | ||||
|  | ||||
| 			switch (htlcStatusA) { | ||||
| 				case UNFUNDED: | ||||
| 				case FUNDING_IN_PROGRESS: | ||||
| 					// P2SH-A suddenly not funded? Our best bet at this point is to hope for AT auto-refund | ||||
| 					return false; | ||||
|  | ||||
| 				case REDEEM_IN_PROGRESS: | ||||
| 				case REDEEMED: | ||||
| 					// Double-check that we have redeemed P2SH-A... | ||||
| 					return false; | ||||
|  | ||||
| 				case REFUND_IN_PROGRESS: | ||||
| 				case REFUNDED: | ||||
| 					// Wait for AT to auto-refund | ||||
| 					return false; | ||||
|  | ||||
| 				case FUNDED: { | ||||
| 					Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); | ||||
| 					ECKey redeemKey = ECKey.fromPrivate(decodedTradePrivateKey); | ||||
| 					List<TransactionOutput> fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA); | ||||
|  | ||||
| 					Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoiny.getNetworkParameters(), redeemAmount, redeemKey, | ||||
| 							fundingOutputs, redeemScriptA, decodedSecret, foreignBlockchainReceivingAccountInfo); | ||||
|  | ||||
| 					bitcoiny.broadcastTransaction(p2shRedeemTransaction); | ||||
| 					LOGGER.info(String.format("P2SH address %s redeemed!", p2shAddressA)); | ||||
| 					return true; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} catch (ForeignBlockchainException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, e); | ||||
| 		} | ||||
|  | ||||
| 		return false; | ||||
| 	} | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/refund/{ataddress}") | ||||
| 	@Operation( | ||||
| 			summary = "Refunds HTLC associated with supplied AT", | ||||
| 			description = "To be used by a QORT buyer (Alice) who needs to refund their LTC/DOGE/etc that is stuck in a P2SH.<br>" + | ||||
| 					"This requires Alice's trade bot data to be present in the database for this AT.<br>" + | ||||
| 					"It will fail if it's already redeemed by the seller, or if the lockTime (60 minutes) hasn't passed yet.", | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public boolean refundHtlc(@HeaderParam(Security.API_KEY_HEADER) String apiKey, @PathParam("ataddress") String atAddress) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); | ||||
| 			TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null); | ||||
| 			if (tradeBotData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			if (tradeBotData.getForeignKey() == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			ATData atData = repository.getATRepository().fromATAddress(atAddress); | ||||
| 			if (atData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); | ||||
|  | ||||
| 			ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); | ||||
| 			if (acct == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			// Determine foreign blockchain receive address for refund | ||||
| 			Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain(); | ||||
| 			String receiveAddress = bitcoiny.getUnusedReceiveAddress(tradeBotData.getForeignKey()); | ||||
|  | ||||
| 			return this.doRefundHtlc(atAddress, receiveAddress); | ||||
|  | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} catch (ForeignBlockchainException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/refundAll") | ||||
| 	@Operation( | ||||
| 			summary = "Refunds HTLC for all applicable ATs in tradebot data", | ||||
| 			description = "To be used by a QORT buyer (Alice) who needs to refund their LTC/DOGE/etc proceeds that are stuck in P2SH transactions.<br>" + | ||||
| 					"This requires Alice's trade bot data to be present in the database for this AT.<br>" + | ||||
| 					"It will fail if it's already redeemed by the seller, or if the lockTime (60 minutes) hasn't passed yet.", | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public boolean refundAllHtlc(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
| 		boolean success = false; | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); | ||||
|  | ||||
| 			for (TradeBotData tradeBotData : allTradeBotData) { | ||||
| 				String atAddress = tradeBotData.getAtAddress(); | ||||
| 				if (atAddress == null) { | ||||
| 					LOGGER.info("Missing AT address in tradebot data", atAddress); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				String tradeState = tradeBotData.getState(); | ||||
| 				if (tradeState == null) { | ||||
| 					LOGGER.info("Missing trade state for AT {}", atAddress); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				if (tradeState.startsWith("BOB")) { | ||||
| 					LOGGER.info("AT {} isn't refundable because it is a sell order", atAddress); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				ATData atData = repository.getATRepository().fromATAddress(atAddress); | ||||
| 				if (atData == null) { | ||||
| 					LOGGER.info("Couldn't find AT with address {}", atAddress); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); | ||||
| 				if (acct == null) { | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); | ||||
| 				if (crossChainTradeData == null) { | ||||
| 					LOGGER.info("Couldn't find crosschain trade data for AT {}", atAddress); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				if (tradeBotData.getForeignKey() == null) { | ||||
| 					LOGGER.info("Couldn't find foreign key for AT {}", atAddress); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				try { | ||||
| 					// Determine foreign blockchain receive address for refund | ||||
| 					Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain(); | ||||
| 					String receivingAddress = bitcoiny.getUnusedReceiveAddress(tradeBotData.getForeignKey()); | ||||
|  | ||||
| 					LOGGER.info("Attempting to refund P2SH balance associated with AT {}...", atAddress); | ||||
| 					boolean refunded = this.doRefundHtlc(atAddress, receivingAddress); | ||||
| 					if (refunded) { | ||||
| 						LOGGER.info("Refunded P2SH balance associated with AT {}", atAddress); | ||||
| 						success = true; | ||||
| 					} | ||||
| 					else { | ||||
| 						LOGGER.info("Couldn't refund P2SH balance associated with AT {}. Already redeemed?", atAddress); | ||||
| 					} | ||||
| 				} catch (ApiException | ForeignBlockchainException e) { | ||||
| 					LOGGER.info("Couldn't refund P2SH balance associated with AT {}. Missing data?", atAddress); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
|  | ||||
| 		return success; | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	private boolean doRefundHtlc(String atAddress, String receiveAddress) { | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|  | ||||
| 			ATData atData = repository.getATRepository().fromATAddress(atAddress); | ||||
| 			if (atData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); | ||||
|  | ||||
| 			ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); | ||||
| 			if (acct == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); | ||||
| 			if (crossChainTradeData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			// If the AT is "finished" then it will have a zero balance | ||||
| 			// In these cases we should avoid HTLC refunds if tbe QORT haven't been returned to the seller | ||||
| 			if (atData.getIsFinished() && crossChainTradeData.mode != AcctMode.REFUNDED && crossChainTradeData.mode != AcctMode.CANCELLED) { | ||||
| 				LOGGER.info(String.format("Skipping AT %s because the QORT has already been redemed", atAddress)); | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
| 			} | ||||
|  | ||||
| 			List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); | ||||
| 			TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null); | ||||
| 			if (tradeBotData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain(); | ||||
| 			if (bitcoiny.getClass() == Bitcoin.class) { | ||||
| 				LOGGER.info("Refunding a Bitcoin HTLC is not yet supported"); | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
| 			} | ||||
|  | ||||
| 			int lockTime = tradeBotData.getLockTimeA(); | ||||
|  | ||||
| 			// We can't refund P2SH-A until lockTime-A has passed | ||||
| 			if (NTP.getTime() <= lockTime * 1000L) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); | ||||
|  | ||||
| 			// We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) | ||||
| 			int medianBlockTime = bitcoiny.getMedianBlockTime(); | ||||
| 			if (medianBlockTime <= lockTime) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); | ||||
|  | ||||
| 			byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); | ||||
| 			String p2shAddressA = bitcoiny.deriveP2shAddress(redeemScriptA); | ||||
| 			LOGGER.info(String.format("Refunding P2SH address: %s", p2shAddressA)); | ||||
|  | ||||
| 			// Fee for redeem/refund is subtracted from P2SH-A balance. | ||||
| 			long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout); | ||||
| 			long p2shFee = bitcoiny.getP2shFee(feeTimestamp); | ||||
| 			long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; | ||||
| 			BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA); | ||||
|  | ||||
| 			switch (htlcStatusA) { | ||||
| 				case UNFUNDED: | ||||
| 				case FUNDING_IN_PROGRESS: | ||||
| 					// Still waiting for P2SH-A to be funded... | ||||
| 					throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); | ||||
|  | ||||
| 				case REDEEM_IN_PROGRESS: | ||||
| 				case REDEEMED: | ||||
| 				case REFUND_IN_PROGRESS: | ||||
| 				case REFUNDED: | ||||
| 					// Too late! | ||||
| 					return false; | ||||
|  | ||||
| 				case FUNDED:{ | ||||
| 					Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); | ||||
| 					ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); | ||||
| 					List<TransactionOutput> fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA); | ||||
|  | ||||
| 					// Validate the destination foreign blockchain address | ||||
| 					Address receiving = Address.fromString(bitcoiny.getNetworkParameters(), receiveAddress); | ||||
| 					if (receiving.getOutputScriptType() != Script.ScriptType.P2PKH) | ||||
| 						throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 					Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoiny.getNetworkParameters(), refundAmount, refundKey, | ||||
| 							fundingOutputs, redeemScriptA, lockTime, receiving.getHash()); | ||||
|  | ||||
| 					bitcoiny.broadcastTransaction(p2shRefundTransaction); | ||||
| 					return true; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} catch (ForeignBlockchainException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, e); | ||||
| 		} | ||||
|  | ||||
| 		return false; | ||||
| 	} | ||||
|  | ||||
| 	private long calcFeeTimestamp(int lockTimeA, int tradeTimeout) { | ||||
| 		return (lockTimeA - tradeTimeout * 60) * 1000L; | ||||
| 	} | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,148 @@ | ||||
| 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.security.SecurityRequirement; | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
| import org.qortal.account.PrivateKeyAccount; | ||||
| import org.qortal.api.ApiError; | ||||
| import org.qortal.api.ApiErrors; | ||||
| import org.qortal.api.ApiExceptionFactory; | ||||
| import org.qortal.api.Security; | ||||
| import org.qortal.api.model.CrossChainSecretRequest; | ||||
| import org.qortal.crosschain.AcctMode; | ||||
| import org.qortal.crosschain.LitecoinACCTv1; | ||||
| import org.qortal.crypto.Crypto; | ||||
| import org.qortal.data.at.ATData; | ||||
| import org.qortal.data.crosschain.CrossChainTradeData; | ||||
| import org.qortal.group.Group; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.RepositoryManager; | ||||
| import org.qortal.transaction.MessageTransaction; | ||||
| import org.qortal.transaction.Transaction.ValidationResult; | ||||
| import org.qortal.transform.TransformationException; | ||||
| import org.qortal.transform.Transformer; | ||||
| import org.qortal.transform.transaction.MessageTransactionTransformer; | ||||
| import org.qortal.utils.Base58; | ||||
| import org.qortal.utils.NTP; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.HeaderParam; | ||||
| import javax.ws.rs.POST; | ||||
| import javax.ws.rs.Path; | ||||
| import javax.ws.rs.core.Context; | ||||
| import javax.ws.rs.core.MediaType; | ||||
| import java.util.Arrays; | ||||
| import java.util.Random; | ||||
|  | ||||
| @Path("/crosschain/LitecoinACCTv1") | ||||
| @Tag(name = "Cross-Chain (LitecoinACCTv1)") | ||||
| public class CrossChainLitecoinACCTv1Resource { | ||||
|  | ||||
| 	@Context | ||||
| 	HttpServletRequest request; | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/redeemmessage") | ||||
| 	@Operation( | ||||
| 		summary = "Signs and broadcasts a 'redeem' MESSAGE transaction that sends secrets to AT, releasing funds to partner", | ||||
| 		description = "Specify address of cross-chain AT that needs to be messaged, Alice's trade private key, the 32-byte secret,<br>" | ||||
| 			+ "and an address for receiving QORT from AT. All of these can be found in Alice's trade bot data.<br>" | ||||
| 			+ "AT needs to be in 'trade' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!<br>" | ||||
| 			+ "You need to use the private key that the AT considers the trade 'partner' otherwise the MESSAGE transaction will be invalid.", | ||||
| 		requestBody = @RequestBody( | ||||
| 			required = true, | ||||
| 			content = @Content( | ||||
| 				mediaType = MediaType.APPLICATION_JSON, | ||||
| 				schema = @Schema( | ||||
| 					implementation = CrossChainSecretRequest.class | ||||
| 				) | ||||
| 			) | ||||
| 		), | ||||
| 		responses = { | ||||
| 			@ApiResponse( | ||||
| 				content = @Content( | ||||
| 					schema = @Schema( | ||||
| 						type = "string" | ||||
| 					) | ||||
| 				) | ||||
| 			) | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public boolean buildRedeemMessage(@HeaderParam(Security.API_KEY_HEADER) String apiKey, CrossChainSecretRequest secretRequest) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		byte[] partnerPrivateKey = secretRequest.partnerPrivateKey; | ||||
|  | ||||
| 		if (partnerPrivateKey == null || partnerPrivateKey.length != Transformer.PRIVATE_KEY_LENGTH) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); | ||||
|  | ||||
| 		if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress)) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); | ||||
|  | ||||
| 		if (secretRequest.secret == null || secretRequest.secret.length != LitecoinACCTv1.SECRET_LENGTH) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); | ||||
|  | ||||
| 		if (secretRequest.receivingAddress == null || !Crypto.isValidAddress(secretRequest.receivingAddress)) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress); | ||||
| 			CrossChainTradeData crossChainTradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); | ||||
|  | ||||
| 			if (crossChainTradeData.mode != AcctMode.TRADING) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 			byte[] partnerPublicKey = new PrivateKeyAccount(null, partnerPrivateKey).getPublicKey(); | ||||
| 			String partnerAddress = Crypto.toAddress(partnerPublicKey); | ||||
|  | ||||
| 			// MESSAGE must come from address that AT considers trade partner | ||||
| 			if (!crossChainTradeData.qortalPartnerAddress.equals(partnerAddress)) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); | ||||
|  | ||||
| 			// Good to make MESSAGE | ||||
|  | ||||
| 			byte[] messageData = LitecoinACCTv1.buildRedeemMessage(secretRequest.secret, secretRequest.receivingAddress); | ||||
|  | ||||
| 			PrivateKeyAccount sender = new PrivateKeyAccount(repository, partnerPrivateKey); | ||||
| 			MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, secretRequest.atAddress, messageData, false, false); | ||||
|  | ||||
| 			messageTransaction.computeNonce(); | ||||
| 			messageTransaction.sign(sender); | ||||
|  | ||||
| 			// reset repository state to prevent deadlock | ||||
| 			repository.discardChanges(); | ||||
| 			ValidationResult result = messageTransaction.importAsUnconfirmed(); | ||||
|  | ||||
| 			if (result != ValidationResult.OK) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); | ||||
|  | ||||
| 			return true; | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private ATData fetchAtDataWithChecking(Repository repository, String atAddress) throws DataException { | ||||
| 		ATData atData = repository.getATRepository().fromATAddress(atAddress); | ||||
| 		if (atData == null) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); | ||||
|  | ||||
| 		// Must be correct AT - check functionality using code hash | ||||
| 		if (!Arrays.equals(atData.getCodeHash(), LitecoinACCTv1.CODE_BYTES_HASH)) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 		// No point sending message to AT that's finished | ||||
| 		if (atData.getIsFinished()) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 		return atData; | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @@ -6,11 +6,13 @@ 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.security.SecurityRequirement; | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
|  | ||||
| import java.util.List; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.HeaderParam; | ||||
| import javax.ws.rs.POST; | ||||
| import javax.ws.rs.Path; | ||||
| import javax.ws.rs.core.Context; | ||||
| @@ -22,9 +24,9 @@ import org.qortal.api.ApiErrors; | ||||
| import org.qortal.api.ApiExceptionFactory; | ||||
| import org.qortal.api.Security; | ||||
| import org.qortal.api.model.crosschain.LitecoinSendRequest; | ||||
| import org.qortal.crosschain.BitcoinyTransaction; | ||||
| import org.qortal.crosschain.ForeignBlockchainException; | ||||
| import org.qortal.crosschain.Litecoin; | ||||
| import org.qortal.crosschain.SimpleTransaction; | ||||
|  | ||||
| @Path("/crosschain/ltc") | ||||
| @Tag(name = "Cross-Chain (Litecoin)") | ||||
| @@ -56,7 +58,8 @@ public class CrossChainLitecoinResource { | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) | ||||
| 	public String getLitecoinWalletBalance(String key58) { | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String getLitecoinWalletBalance(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		Litecoin litecoin = Litecoin.getInstance(); | ||||
| @@ -64,11 +67,16 @@ public class CrossChainLitecoinResource { | ||||
| 		if (!litecoin.isValidDeterministicKey(key58)) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); | ||||
|  | ||||
| 		Long balance = litecoin.getWalletBalance(key58); | ||||
| 		if (balance == null) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); | ||||
| 		try { | ||||
| 			Long balance = litecoin.getWalletBalanceFromTransactions(key58); | ||||
| 			if (balance == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); | ||||
|  | ||||
| 		return balance.toString(); | ||||
| 			return balance.toString(); | ||||
|  | ||||
| 		} catch (ForeignBlockchainException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@POST | ||||
| @@ -89,12 +97,13 @@ public class CrossChainLitecoinResource { | ||||
| 		), | ||||
| 		responses = { | ||||
| 			@ApiResponse( | ||||
| 				content = @Content(array = @ArraySchema( schema = @Schema( implementation = BitcoinyTransaction.class ) ) ) | ||||
| 				content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) ) | ||||
| 			) | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) | ||||
| 	public List<BitcoinyTransaction> getLitecoinWalletTransactions(String key58) { | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public List<SimpleTransaction> getLitecoinWalletTransactions(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		Litecoin litecoin = Litecoin.getInstance(); | ||||
| @@ -130,7 +139,8 @@ public class CrossChainLitecoinResource { | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) | ||||
| 	public String sendBitcoin(LitecoinSendRequest litecoinSendRequest) { | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String sendBitcoin(@HeaderParam(Security.API_KEY_HEADER) String apiKey, LitecoinSendRequest litecoinSendRequest) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		if (litecoinSendRequest.litecoinAmount <= 0) | ||||
| @@ -164,4 +174,4 @@ public class CrossChainLitecoinResource { | ||||
| 		return spendTransaction.getTxId().toString(); | ||||
| 	} | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| package org.qortal.api.resource; | ||||
|  | ||||
| import com.google.common.primitives.Longs; | ||||
| import io.swagger.v3.oas.annotations.Operation; | ||||
| import io.swagger.v3.oas.annotations.Parameter; | ||||
| import io.swagger.v3.oas.annotations.media.ArraySchema; | ||||
| @@ -10,20 +11,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; | ||||
| import io.swagger.v3.oas.annotations.security.SecurityRequirement; | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
|  | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.Collections; | ||||
| import java.util.List; | ||||
| import java.util.Map; | ||||
| import java.util.Random; | ||||
| import java.util.*; | ||||
| import java.util.function.Supplier; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.DELETE; | ||||
| import javax.ws.rs.GET; | ||||
| import javax.ws.rs.Path; | ||||
| import javax.ws.rs.PathParam; | ||||
| import javax.ws.rs.QueryParam; | ||||
| import javax.ws.rs.*; | ||||
| import javax.ws.rs.core.Context; | ||||
| import javax.ws.rs.core.MediaType; | ||||
|  | ||||
| @@ -95,7 +87,7 @@ public class CrossChainResource { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 		final boolean isExecutable = true; | ||||
| 		List<CrossChainTradeData> crossChainTradesData = new ArrayList<>(); | ||||
| 		List<CrossChainTradeData> crossChainTrades = new ArrayList<>(); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain); | ||||
| @@ -108,11 +100,27 @@ public class CrossChainResource { | ||||
|  | ||||
| 				for (ATData atData : atsData) { | ||||
| 					CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); | ||||
| 					crossChainTradesData.add(crossChainTradeData); | ||||
| 					if (crossChainTradeData.mode == AcctMode.OFFERING) { | ||||
| 						crossChainTrades.add(crossChainTradeData); | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			return crossChainTradesData; | ||||
| 			// Sort the trades by timestamp | ||||
| 			if (reverse != null && reverse) { | ||||
| 				crossChainTrades.sort((a, b) -> Longs.compare(b.creationTimestamp, a.creationTimestamp)); | ||||
| 			} | ||||
| 			else { | ||||
| 				crossChainTrades.sort((a, b) -> Longs.compare(a.creationTimestamp, b.creationTimestamp)); | ||||
| 			} | ||||
|  | ||||
| 			if (limit != null && limit > 0) { | ||||
| 				// Make sure to not return more than the limit | ||||
| 				int upperLimit = Math.min(limit, crossChainTrades.size()); | ||||
| 				crossChainTrades = crossChainTrades.subList(0, upperLimit); | ||||
| 			} | ||||
|  | ||||
| 			return crossChainTrades; | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| @@ -195,6 +203,11 @@ public class CrossChainResource { | ||||
|  | ||||
| 			if (minimumTimestamp != null) { | ||||
| 				minimumFinalHeight = repository.getBlockRepository().getHeightFromTimestamp(minimumTimestamp); | ||||
| 				// If not found in the block repository it will return either 0 or 1 | ||||
| 				if (minimumFinalHeight == 0 || minimumFinalHeight == 1) { | ||||
| 					// Try the archive | ||||
| 					minimumFinalHeight = repository.getBlockArchiveRepository().getHeightFromTimestamp(minimumTimestamp); | ||||
| 				} | ||||
|  | ||||
| 				if (minimumFinalHeight == 0) | ||||
| 					// We don't have any blocks since minimumTimestamp, let alone trades, so nothing to return | ||||
| @@ -222,12 +235,30 @@ public class CrossChainResource { | ||||
|  | ||||
| 					// We also need block timestamp for use as trade timestamp | ||||
| 					long timestamp = repository.getBlockRepository().getTimestampFromHeight(atState.getHeight()); | ||||
| 					if (timestamp == 0) { | ||||
| 						// Try the archive | ||||
| 						timestamp = repository.getBlockArchiveRepository().getTimestampFromHeight(atState.getHeight()); | ||||
| 					} | ||||
|  | ||||
| 					CrossChainTradeSummary crossChainTradeSummary = new CrossChainTradeSummary(crossChainTradeData, timestamp); | ||||
| 					crossChainTrades.add(crossChainTradeSummary); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			// Sort the trades by timestamp | ||||
| 			if (reverse != null && reverse) { | ||||
| 				crossChainTrades.sort((a, b) -> Longs.compare(b.getTradeTimestamp(), a.getTradeTimestamp())); | ||||
| 			} | ||||
| 			else { | ||||
| 				crossChainTrades.sort((a, b) -> Longs.compare(a.getTradeTimestamp(), b.getTradeTimestamp())); | ||||
| 			} | ||||
|  | ||||
| 			if (limit != null && limit > 0) { | ||||
| 				// Make sure to not return more than the limit | ||||
| 				int upperLimit = Math.min(limit, crossChainTrades.size()); | ||||
| 				crossChainTrades = crossChainTrades.subList(0, upperLimit); | ||||
| 			} | ||||
|  | ||||
| 			return crossChainTrades; | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| @@ -255,15 +286,27 @@ public class CrossChainResource { | ||||
| 					description = "foreign blockchain", | ||||
| 					example = "LITECOIN", | ||||
| 					schema = @Schema(implementation = SupportedBlockchain.class) | ||||
| 				) @PathParam("blockchain") SupportedBlockchain foreignBlockchain) { | ||||
| 				) @PathParam("blockchain") SupportedBlockchain foreignBlockchain, | ||||
| 			@Parameter( | ||||
| 					description = "Maximum number of trades to include in price calculation", | ||||
| 					example = "10", | ||||
| 					schema = @Schema(type = "integer", defaultValue = "10") | ||||
| 			) @QueryParam("maxtrades") Integer maxtrades, | ||||
| 			@Parameter( | ||||
| 					description = "Display price in terms of foreign currency per unit QORT", | ||||
| 					example = "false", | ||||
| 					schema = @Schema(type = "boolean", defaultValue = "false") | ||||
| 			) @QueryParam("inverse") Boolean inverse) { | ||||
| 		// foreignBlockchain is required | ||||
| 		if (foreignBlockchain == null) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 		// We want both a minimum of 5 trades and enough trades to span at least 4 hours | ||||
| 		int minimumCount = 5; | ||||
| 		int maximumCount = maxtrades != null ? maxtrades : 10; | ||||
| 		long minimumPeriod = 4 * 60 * 60 * 1000L; // ms | ||||
| 		Boolean isFinished = Boolean.TRUE; | ||||
| 		boolean useInversePrice = (inverse != null && inverse == true); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain); | ||||
| @@ -271,21 +314,49 @@ public class CrossChainResource { | ||||
| 			long totalForeign = 0; | ||||
| 			long totalQort = 0; | ||||
|  | ||||
| 			Map<Long, CrossChainTradeData> reverseSortedTradeData = new TreeMap<>(Collections.reverseOrder()); | ||||
|  | ||||
| 			// Collect recent AT states for each ACCT version | ||||
| 			for (Map.Entry<ByteArray, Supplier<ACCT>> acctInfo : acctsByCodeHash.entrySet()) { | ||||
| 				byte[] codeHash = acctInfo.getKey().value; | ||||
| 				ACCT acct = acctInfo.getValue().get(); | ||||
|  | ||||
| 				List<ATStateData> atStates = repository.getATRepository().getMatchingFinalATStatesQuorum(codeHash, | ||||
| 						isFinished, acct.getModeByteOffset(), (long) AcctMode.REDEEMED.value, minimumCount, minimumPeriod); | ||||
| 						isFinished, acct.getModeByteOffset(), (long) AcctMode.REDEEMED.value, minimumCount, maximumCount, minimumPeriod); | ||||
|  | ||||
| 				for (ATStateData atState : atStates) { | ||||
| 					// We also need block timestamp for use as trade timestamp | ||||
| 					long timestamp = repository.getBlockRepository().getTimestampFromHeight(atState.getHeight()); | ||||
| 					if (timestamp == 0) { | ||||
| 						// Try the archive | ||||
| 						timestamp = repository.getBlockArchiveRepository().getTimestampFromHeight(atState.getHeight()); | ||||
| 					} | ||||
|  | ||||
| 					CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); | ||||
| 					totalForeign += crossChainTradeData.expectedForeignAmount; | ||||
| 					totalQort += crossChainTradeData.qortAmount; | ||||
| 					reverseSortedTradeData.put(timestamp, crossChainTradeData); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			return Amounts.scaledDivide(totalQort, totalForeign); | ||||
| 			// Loop through the sorted map and calculate the average price | ||||
| 			// Also remove elements beyond the maxtrades limit | ||||
| 			Set set = reverseSortedTradeData.entrySet(); | ||||
| 			Iterator i = set.iterator(); | ||||
| 			int index = 0; | ||||
| 			while (i.hasNext()) { | ||||
| 				Map.Entry tradeDataMap = (Map.Entry)i.next(); | ||||
| 				CrossChainTradeData crossChainTradeData = (CrossChainTradeData) tradeDataMap.getValue(); | ||||
|  | ||||
| 				if (maxtrades != null && index >= maxtrades) { | ||||
| 					// We've reached the limit | ||||
| 					break; | ||||
| 				} | ||||
|  | ||||
| 				totalForeign += crossChainTradeData.expectedForeignAmount; | ||||
| 				totalQort += crossChainTradeData.qortAmount; | ||||
| 				index++; | ||||
| 			} | ||||
|  | ||||
| 			return useInversePrice ? Amounts.scaledDivide(totalForeign, totalQort) : Amounts.scaledDivide(totalQort, totalForeign); | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		} | ||||
| @@ -320,7 +391,7 @@ public class CrossChainResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String cancelTrade(CrossChainCancelRequest cancelRequest) { | ||||
| 	public String cancelTrade(@HeaderParam(Security.API_KEY_HEADER) String apiKey, CrossChainCancelRequest cancelRequest) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		byte[] creatorPublicKey = cancelRequest.creatorPublicKey; | ||||
| @@ -415,4 +486,4 @@ public class CrossChainResource { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -7,17 +7,14 @@ 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.security.SecurityRequirement; | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
|  | ||||
| import java.util.List; | ||||
| import java.util.stream.Collectors; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.DELETE; | ||||
| import javax.ws.rs.GET; | ||||
| import javax.ws.rs.POST; | ||||
| import javax.ws.rs.Path; | ||||
| import javax.ws.rs.QueryParam; | ||||
| import javax.ws.rs.*; | ||||
| import javax.ws.rs.core.Context; | ||||
| import javax.ws.rs.core.MediaType; | ||||
|  | ||||
| @@ -30,6 +27,7 @@ import org.qortal.api.Security; | ||||
| import org.qortal.api.model.crosschain.TradeBotCreateRequest; | ||||
| import org.qortal.api.model.crosschain.TradeBotRespondRequest; | ||||
| import org.qortal.asset.Asset; | ||||
| import org.qortal.controller.Controller; | ||||
| import org.qortal.controller.tradebot.AcctTradeBot; | ||||
| import org.qortal.controller.tradebot.TradeBot; | ||||
| import org.qortal.crosschain.ForeignBlockchain; | ||||
| @@ -68,7 +66,9 @@ public class CrossChainTradeBotResource { | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public List<TradeBotData> getTradeBotStates( | ||||
| 			@HeaderParam(Security.API_KEY_HEADER) String apiKey, | ||||
| 			@Parameter( | ||||
| 					description = "Limit to specific blockchain", | ||||
| 					example = "LITECOIN", | ||||
| @@ -107,9 +107,10 @@ public class CrossChainTradeBotResource { | ||||
| 			) | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.INSUFFICIENT_BALANCE, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.INSUFFICIENT_BALANCE, ApiError.REPOSITORY_ISSUE, ApiError.ORDER_SIZE_TOO_SMALL}) | ||||
| 	@SuppressWarnings("deprecation") | ||||
| 	public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String tradeBotCreator(@HeaderParam(Security.API_KEY_HEADER) String apiKey, TradeBotCreateRequest tradeBotCreateRequest) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		if (tradeBotCreateRequest.foreignBlockchain == null) | ||||
| @@ -128,10 +129,16 @@ public class CrossChainTradeBotResource { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
|  | ||||
| 		if (tradeBotCreateRequest.foreignAmount == null || tradeBotCreateRequest.foreignAmount <= 0) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ORDER_SIZE_TOO_SMALL); | ||||
|  | ||||
| 		if (tradeBotCreateRequest.foreignAmount < foreignBlockchain.getMinimumOrderAmount()) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ORDER_SIZE_TOO_SMALL); | ||||
|  | ||||
| 		if (tradeBotCreateRequest.qortAmount <= 0 || tradeBotCreateRequest.fundingQortAmount <= 0) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ORDER_SIZE_TOO_SMALL); | ||||
|  | ||||
| 		if (!Controller.getInstance().isUpToDate()) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCKCHAIN_NEEDS_SYNC); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			// Do some simple checking first | ||||
| @@ -172,7 +179,8 @@ public class CrossChainTradeBotResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SuppressWarnings("deprecation") | ||||
| 	public String tradeBotResponder(TradeBotRespondRequest tradeBotRespondRequest) { | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String tradeBotResponder(@HeaderParam(Security.API_KEY_HEADER) String apiKey, TradeBotRespondRequest tradeBotRespondRequest) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		final String atAddress = tradeBotRespondRequest.atAddress; | ||||
| @@ -190,6 +198,9 @@ public class CrossChainTradeBotResource { | ||||
| 		if (tradeBotRespondRequest.receivingAddress == null || !Crypto.isValidAddress(tradeBotRespondRequest.receivingAddress)) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); | ||||
|  | ||||
| 		if (!Controller.getInstance().isUpToDate()) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCKCHAIN_NEEDS_SYNC); | ||||
|  | ||||
| 		// Extract data from cross-chain trading AT | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			ATData atData = fetchAtDataWithChecking(repository, atAddress); | ||||
| @@ -250,7 +261,8 @@ public class CrossChainTradeBotResource { | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) | ||||
| 	public String tradeBotDelete(String tradePrivateKey58) { | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String tradeBotDelete(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String tradePrivateKey58) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		final byte[] tradePrivateKey; | ||||
| @@ -283,4 +295,4 @@ public class CrossChainTradeBotResource { | ||||
| 		return atData; | ||||
| 	} | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
							
								
								
									
										176
									
								
								src/main/java/org/qortal/api/resource/ListsResource.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								src/main/java/org/qortal/api/resource/ListsResource.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,176 @@ | ||||
| package org.qortal.api.resource; | ||||
|  | ||||
| import io.swagger.v3.oas.annotations.Operation; | ||||
| import io.swagger.v3.oas.annotations.media.ArraySchema; | ||||
| 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.security.SecurityRequirement; | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
|  | ||||
| import org.qortal.api.*; | ||||
| import org.qortal.api.model.ListRequest; | ||||
| import org.qortal.crypto.Crypto; | ||||
| import org.qortal.data.account.AccountData; | ||||
| import org.qortal.list.ResourceListManager; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.RepositoryManager; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.*; | ||||
| import javax.ws.rs.core.Context; | ||||
| import javax.ws.rs.core.MediaType; | ||||
|  | ||||
|  | ||||
| @Path("/lists") | ||||
| @Tag(name = "Lists") | ||||
| public class ListsResource { | ||||
|  | ||||
| 	@Context | ||||
| 	HttpServletRequest request; | ||||
|  | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/{listName}") | ||||
| 	@Operation( | ||||
| 			summary = "Add items to a new or existing list", | ||||
| 			requestBody = @RequestBody( | ||||
| 					required = true, | ||||
| 					content = @Content( | ||||
| 							mediaType = MediaType.APPLICATION_JSON, | ||||
| 							schema = @Schema( | ||||
| 									implementation = ListRequest.class | ||||
| 							) | ||||
| 					) | ||||
| 			), | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							description = "Returns true if all items were processed, false if any couldn't be " + | ||||
| 									"processed, or an exception on failure. If false or an exception is returned, " + | ||||
| 									"the list will not be updated, and the request will need to be re-issued.", | ||||
| 							content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String addItemstoList(@HeaderParam(Security.API_KEY_HEADER) String apiKey, | ||||
| 								 @PathParam("listName") String listName, | ||||
| 								 ListRequest listRequest) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		if (listName == null) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
| 		} | ||||
|  | ||||
| 		if (listRequest == null || listRequest.items == null) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
| 		} | ||||
|  | ||||
| 		int successCount = 0; | ||||
| 		int errorCount = 0; | ||||
|  | ||||
| 		for (String item : listRequest.items) { | ||||
|  | ||||
| 			boolean success = ResourceListManager.getInstance().addToList(listName, item, false); | ||||
| 			if (success) { | ||||
| 				successCount++; | ||||
| 			} | ||||
| 			else { | ||||
| 				errorCount++; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if (successCount > 0 && errorCount == 0) { | ||||
| 			// All were successful, so save the list | ||||
| 			ResourceListManager.getInstance().saveList(listName); | ||||
| 			return "true"; | ||||
| 		} | ||||
| 		else { | ||||
| 			// Something went wrong, so revert | ||||
| 			ResourceListManager.getInstance().revertList(listName); | ||||
| 			return "false"; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@DELETE | ||||
| 	@Path("/{listName}") | ||||
| 	@Operation( | ||||
| 			summary = "Remove one or more items from a list", | ||||
| 			requestBody = @RequestBody( | ||||
| 					required = true, | ||||
| 					content = @Content( | ||||
| 							mediaType = MediaType.APPLICATION_JSON, | ||||
| 							schema = @Schema( | ||||
| 									implementation = ListRequest.class | ||||
| 							) | ||||
| 					) | ||||
| 			), | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							description = "Returns true if all items were processed, false if any couldn't be " + | ||||
| 									"processed, or an exception on failure. If false or an exception is returned, " + | ||||
| 									"the list will not be updated, and the request will need to be re-issued.", | ||||
| 							content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String removeItemsFromList(@HeaderParam(Security.API_KEY_HEADER) String apiKey, | ||||
| 									  @PathParam("listName") String listName, | ||||
| 									  ListRequest listRequest) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		if (listRequest == null || listRequest.items == null) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); | ||||
| 		} | ||||
|  | ||||
| 		int successCount = 0; | ||||
| 		int errorCount = 0; | ||||
|  | ||||
| 		for (String address : listRequest.items) { | ||||
|  | ||||
| 			// Attempt to remove the item | ||||
| 			// Don't save as we will do this at the end of the process | ||||
| 			boolean success = ResourceListManager.getInstance().removeFromList(listName, address, false); | ||||
| 			if (success) { | ||||
| 				successCount++; | ||||
| 			} | ||||
| 			else { | ||||
| 				errorCount++; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if (successCount > 0 && errorCount == 0) { | ||||
| 			// All were successful, so save the list | ||||
| 			ResourceListManager.getInstance().saveList(listName); | ||||
| 			return "true"; | ||||
| 		} | ||||
| 		else { | ||||
| 			// Something went wrong, so revert | ||||
| 			ResourceListManager.getInstance().revertList(listName); | ||||
| 			return "false"; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@GET | ||||
| 	@Path("/{listName}") | ||||
| 	@Operation( | ||||
| 			summary = "Fetch all items in a list", | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							description = "A JSON array of items", | ||||
| 							content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = String.class))) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String getItemsInList(@HeaderParam(Security.API_KEY_HEADER) String apiKey, @PathParam("listName") String listName) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
| 		return ResourceListManager.getInstance().getJSONStringForList(listName); | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @@ -16,19 +16,13 @@ import java.util.List; | ||||
| import java.util.stream.Collectors; | ||||
|  | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.ws.rs.DELETE; | ||||
| import javax.ws.rs.GET; | ||||
| import javax.ws.rs.POST; | ||||
| import javax.ws.rs.Path; | ||||
| import javax.ws.rs.*; | ||||
| import javax.ws.rs.core.Context; | ||||
| import javax.ws.rs.core.MediaType; | ||||
|  | ||||
| import org.qortal.api.ApiError; | ||||
| import org.qortal.api.ApiErrors; | ||||
| import org.qortal.api.ApiException; | ||||
| import org.qortal.api.ApiExceptionFactory; | ||||
| import org.qortal.api.Security; | ||||
| import org.qortal.api.*; | ||||
| import org.qortal.api.model.ConnectedPeer; | ||||
| import org.qortal.api.model.PeersSummary; | ||||
| import org.qortal.controller.Controller; | ||||
| import org.qortal.controller.Synchronizer; | ||||
| import org.qortal.controller.Synchronizer.SynchronizationResult; | ||||
| @@ -133,7 +127,7 @@ public class PeersResource { | ||||
| 		} | ||||
| 	) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public ExecuteProduceConsume.StatsSnapshot getEngineStats() { | ||||
| 	public ExecuteProduceConsume.StatsSnapshot getEngineStats(@HeaderParam(Security.API_KEY_HEADER) String apiKey) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		return Network.getInstance().getStatsSnapshot(); | ||||
| @@ -171,7 +165,7 @@ public class PeersResource { | ||||
| 		ApiError.INVALID_NETWORK_ADDRESS, ApiError.REPOSITORY_ISSUE | ||||
| 	}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String addPeer(String address) { | ||||
| 	public String addPeer(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String address) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		final Long addedWhen = NTP.getTime(); | ||||
| @@ -226,7 +220,7 @@ public class PeersResource { | ||||
| 		ApiError.INVALID_NETWORK_ADDRESS, ApiError.REPOSITORY_ISSUE | ||||
| 	}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String removePeer(String address) { | ||||
| 	public String removePeer(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String address) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try { | ||||
| @@ -262,7 +256,7 @@ public class PeersResource { | ||||
| 		ApiError.REPOSITORY_ISSUE | ||||
| 	}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public String removeKnownPeers(String address) { | ||||
| 	public String removeKnownPeers(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String address) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try { | ||||
| @@ -302,7 +296,7 @@ public class PeersResource { | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE}) | ||||
| 	@SecurityRequirement(name = "apiKey") | ||||
| 	public List<BlockSummaryData> commonBlock(String targetPeerAddress) { | ||||
| 	public List<BlockSummaryData> commonBlock(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String targetPeerAddress) { | ||||
| 		Security.checkApiCallAllowed(request); | ||||
|  | ||||
| 		try { | ||||
| @@ -321,7 +315,7 @@ public class PeersResource { | ||||
| 				boolean force = true; | ||||
| 				List<BlockSummaryData> peerBlockSummaries = new ArrayList<>(); | ||||
|  | ||||
| 				SynchronizationResult findCommonBlockResult = Synchronizer.getInstance().fetchSummariesFromCommonBlock(repository, targetPeer, ourInitialHeight, force, peerBlockSummaries); | ||||
| 				SynchronizationResult findCommonBlockResult = Synchronizer.getInstance().fetchSummariesFromCommonBlock(repository, targetPeer, ourInitialHeight, force, peerBlockSummaries, true); | ||||
| 				if (findCommonBlockResult != SynchronizationResult.OK) | ||||
| 					return null; | ||||
|  | ||||
| @@ -338,4 +332,36 @@ public class PeersResource { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@GET | ||||
| 	@Path("/summary") | ||||
| 	@Operation( | ||||
| 			summary = "Returns total inbound and outbound connections for connected peers", | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							content = @Content( | ||||
| 									mediaType = MediaType.APPLICATION_JSON, | ||||
| 									array = @ArraySchema( | ||||
| 											schema = @Schema( | ||||
| 													implementation = PeersSummary.class | ||||
| 											) | ||||
| 									) | ||||
| 							) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	public PeersSummary peersSummary() { | ||||
| 		PeersSummary peersSummary = new PeersSummary(); | ||||
|  | ||||
| 		List<Peer> connectedPeers = Network.getInstance().getConnectedPeers().stream().collect(Collectors.toList()); | ||||
| 		for (Peer peer : connectedPeers) { | ||||
| 			if (peer.isOutbound()) { | ||||
| 				peersSummary.inboundConnections++; | ||||
| 			} | ||||
| 			else { | ||||
| 				peersSummary.outboundConnections++; | ||||
| 			} | ||||
| 		} | ||||
| 		return peersSummary; | ||||
| 	} | ||||
|  | ||||
| } | ||||
|   | ||||
							
								
								
									
										200
									
								
								src/main/java/org/qortal/api/resource/RenderResource.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								src/main/java/org/qortal/api/resource/RenderResource.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,200 @@ | ||||
| package org.qortal.api.resource; | ||||
|  | ||||
| import javax.servlet.ServletContext; | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.servlet.http.HttpServletResponse; | ||||
| import javax.ws.rs.*; | ||||
| import javax.ws.rs.core.Context; | ||||
| import javax.ws.rs.core.MediaType; | ||||
| import java.io.*; | ||||
| import java.nio.file.Paths; | ||||
| import java.util.Map; | ||||
|  | ||||
| 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.security.SecurityRequirement; | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.api.ApiError; | ||||
| import org.qortal.api.ApiExceptionFactory; | ||||
| import org.qortal.api.Security; | ||||
| import org.qortal.arbitrary.misc.Service; | ||||
| import org.qortal.arbitrary.*; | ||||
| import org.qortal.arbitrary.exception.MissingDataException; | ||||
| import org.qortal.controller.arbitrary.ArbitraryDataRenderManager; | ||||
| import org.qortal.data.transaction.ArbitraryTransactionData.*; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.settings.Settings; | ||||
| import org.qortal.arbitrary.ArbitraryDataFile.*; | ||||
| import org.qortal.utils.Base58; | ||||
|  | ||||
|  | ||||
| @Path("/render") | ||||
| @Tag(name = "Render") | ||||
| public class RenderResource { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(RenderResource.class); | ||||
|  | ||||
|     @Context HttpServletRequest request; | ||||
|     @Context HttpServletResponse response; | ||||
|     @Context ServletContext context; | ||||
|  | ||||
|     @POST | ||||
|     @Path("/preview") | ||||
|     @Operation( | ||||
|             summary = "Generate preview URL based on a user-supplied path and service", | ||||
|             requestBody = @RequestBody( | ||||
|                     required = true, | ||||
|                     content = @Content( | ||||
|                             mediaType = MediaType.TEXT_PLAIN, | ||||
|                             schema = @Schema( | ||||
|                                     type = "string", example = "/Users/user/Documents/MyStaticWebsite" | ||||
|                             ) | ||||
|                     ) | ||||
|             ), | ||||
|             responses = { | ||||
|                     @ApiResponse( | ||||
|                             description = "a temporary URL to preview the website", | ||||
|                             content = @Content( | ||||
|                                     mediaType = MediaType.TEXT_PLAIN, | ||||
|                                     schema = @Schema( | ||||
|                                             type = "string" | ||||
|                                     ) | ||||
|                             ) | ||||
|                     ) | ||||
|             } | ||||
|     ) | ||||
|     @SecurityRequirement(name = "apiKey") | ||||
|     public String preview(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String directoryPath) { | ||||
|         Security.checkApiCallAllowed(request); | ||||
|         Method method = Method.PUT; | ||||
|         Compression compression = Compression.ZIP; | ||||
|  | ||||
|         ArbitraryDataWriter arbitraryDataWriter = new ArbitraryDataWriter(Paths.get(directoryPath), null, Service.WEBSITE, null, method, compression); | ||||
|         try { | ||||
|             arbitraryDataWriter.save(); | ||||
|         } catch (IOException | DataException | InterruptedException | MissingDataException e) { | ||||
|             LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage()); | ||||
|             throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE); | ||||
|         } catch (RuntimeException e) { | ||||
|             LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage()); | ||||
|             throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); | ||||
|         } | ||||
|  | ||||
|         ArbitraryDataFile arbitraryDataFile = arbitraryDataWriter.getArbitraryDataFile(); | ||||
|         if (arbitraryDataFile != null) { | ||||
|             String digest58 = arbitraryDataFile.digest58(); | ||||
|             if (digest58 != null) { | ||||
|                 return "http://localhost:12393/render/hash/" + digest58 + "?secret=" + Base58.encode(arbitraryDataFile.getSecret()); | ||||
|             } | ||||
|         } | ||||
|         return "Unable to generate preview URL"; | ||||
|     } | ||||
|  | ||||
|     @POST | ||||
|     @Path("/authorize/{resourceId}") | ||||
|     @SecurityRequirement(name = "apiKey") | ||||
|     public boolean authorizeResource(@HeaderParam(Security.API_KEY_HEADER) String apiKey, @PathParam("resourceId") String resourceId) { | ||||
|         Security.checkApiCallAllowed(request); | ||||
|         Security.disallowLoopbackRequestsIfAuthBypassEnabled(request); | ||||
|         ArbitraryDataResource resource = new ArbitraryDataResource(resourceId, null, null, null); | ||||
|         ArbitraryDataRenderManager.getInstance().addToAuthorizedResources(resource); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     @POST | ||||
|     @Path("authorize/{service}/{resourceId}") | ||||
|     @SecurityRequirement(name = "apiKey") | ||||
|     public boolean authorizeResource(@HeaderParam(Security.API_KEY_HEADER) String apiKey, | ||||
|                                      @PathParam("service") Service service, | ||||
|                                      @PathParam("resourceId") String resourceId) { | ||||
|         Security.checkApiCallAllowed(request); | ||||
|         Security.disallowLoopbackRequestsIfAuthBypassEnabled(request); | ||||
|         ArbitraryDataResource resource = new ArbitraryDataResource(resourceId, null, service, null); | ||||
|         ArbitraryDataRenderManager.getInstance().addToAuthorizedResources(resource); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     @POST | ||||
|     @Path("authorize/{service}/{resourceId}/{identifier}") | ||||
|     @SecurityRequirement(name = "apiKey") | ||||
|     public boolean authorizeResource(@HeaderParam(Security.API_KEY_HEADER) String apiKey, | ||||
|                                      @PathParam("service") Service service, | ||||
|                                      @PathParam("resourceId") String resourceId, | ||||
|                                      @PathParam("identifier") String identifier) { | ||||
|         Security.checkApiCallAllowed(request); | ||||
|         Security.disallowLoopbackRequestsIfAuthBypassEnabled(request); | ||||
|         ArbitraryDataResource resource = new ArbitraryDataResource(resourceId, null, service, identifier); | ||||
|         ArbitraryDataRenderManager.getInstance().addToAuthorizedResources(resource); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     @GET | ||||
|     @Path("/signature/{signature}") | ||||
|     @SecurityRequirement(name = "apiKey") | ||||
|     public HttpServletResponse getIndexBySignature(@PathParam("signature") String signature) { | ||||
|         Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null); | ||||
|         return this.get(signature, ResourceIdType.SIGNATURE, null, "/", null, "/render/signature", true, true); | ||||
|     } | ||||
|  | ||||
|     @GET | ||||
|     @Path("/signature/{signature}/{path:.*}") | ||||
|     @SecurityRequirement(name = "apiKey") | ||||
|     public HttpServletResponse getPathBySignature(@PathParam("signature") String signature, @PathParam("path") String inPath) { | ||||
|         Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null); | ||||
|         return this.get(signature, ResourceIdType.SIGNATURE, null, inPath,null, "/render/signature", true, true); | ||||
|     } | ||||
|  | ||||
|     @GET | ||||
|     @Path("/hash/{hash}") | ||||
|     @SecurityRequirement(name = "apiKey") | ||||
|     public HttpServletResponse getIndexByHash(@PathParam("hash") String hash58, @QueryParam("secret") String secret58) { | ||||
|         Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null); | ||||
|         return this.get(hash58, ResourceIdType.FILE_HASH, Service.WEBSITE, "/", secret58, "/render/hash", true, false); | ||||
|     } | ||||
|  | ||||
|     @GET | ||||
|     @Path("/hash/{hash}/{path:.*}") | ||||
|     @SecurityRequirement(name = "apiKey") | ||||
|     public HttpServletResponse getPathByHash(@PathParam("hash") String hash58, @PathParam("path") String inPath, | ||||
|                                              @QueryParam("secret") String secret58) { | ||||
|         Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null); | ||||
|         return this.get(hash58, ResourceIdType.FILE_HASH, Service.WEBSITE, inPath, secret58, "/render/hash", true, false); | ||||
|     } | ||||
|  | ||||
|     @GET | ||||
|     @Path("{service}/{name}/{path:.*}") | ||||
|     @SecurityRequirement(name = "apiKey") | ||||
|     public HttpServletResponse getPathByName(@PathParam("service") Service service, | ||||
|                                              @PathParam("name") String name, | ||||
|                                              @PathParam("path") String inPath) { | ||||
|         Security.requirePriorAuthorization(request, name, service, null); | ||||
|         String prefix = String.format("/render/%s", service); | ||||
|         return this.get(name, ResourceIdType.NAME, service, inPath, null, prefix, true, true); | ||||
|     } | ||||
|  | ||||
|     @GET | ||||
|     @Path("{service}/{name}") | ||||
|     @SecurityRequirement(name = "apiKey") | ||||
|     public HttpServletResponse getIndexByName(@PathParam("service") Service service, | ||||
|                                               @PathParam("name") String name) { | ||||
|         Security.requirePriorAuthorization(request, name, service, null); | ||||
|         String prefix = String.format("/render/%s", service); | ||||
|         return this.get(name, ResourceIdType.NAME, service, "/", null, prefix, true, true); | ||||
|     } | ||||
|  | ||||
|  | ||||
|  | ||||
|     private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String inPath, | ||||
|                                     String secret58, String prefix, boolean usePrefix, boolean async) { | ||||
|  | ||||
|         ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, inPath, | ||||
|                 secret58, prefix, usePrefix, async, request, response, context); | ||||
|         return renderer.render(); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -9,6 +9,8 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; | ||||
| import io.swagger.v3.oas.annotations.responses.ApiResponse; | ||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||
|  | ||||
| import java.lang.reflect.Constructor; | ||||
| import java.lang.reflect.InvocationTargetException; | ||||
| import java.util.ArrayList; | ||||
| import java.util.List; | ||||
| import java.util.concurrent.TimeUnit; | ||||
| @@ -44,6 +46,7 @@ import org.qortal.transform.transaction.TransactionTransformer; | ||||
| import org.qortal.utils.Base58; | ||||
|  | ||||
| import com.google.common.primitives.Bytes; | ||||
| import org.qortal.utils.NTP; | ||||
|  | ||||
| @Path("/transactions") | ||||
| @Tag(name = "Transactions") | ||||
| @@ -348,7 +351,7 @@ public class TransactionsResource { | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(startBlock, blockLimit, txGroupId, | ||||
| 					txTypes, null, address, confirmationStatus, limit, offset, reverse); | ||||
| 					txTypes, null, null, address, confirmationStatus, limit, offset, reverse); | ||||
|  | ||||
| 			// Expand signatures to transactions | ||||
| 			List<TransactionData> transactions = new ArrayList<>(signatures.size()); | ||||
| @@ -363,6 +366,83 @@ public class TransactionsResource { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@GET | ||||
| 	@Path("/unitfee") | ||||
| 	@Operation( | ||||
| 			summary = "Get transaction unit fee", | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							content = @Content( | ||||
| 									mediaType = MediaType.TEXT_PLAIN, | ||||
| 									schema = @Schema( | ||||
| 											type = "number" | ||||
| 									) | ||||
| 							) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@ApiErrors({ | ||||
| 			ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE | ||||
| 	}) | ||||
| 	public long getTransactionUnitFee(@QueryParam("txType") TransactionType txType, | ||||
|                                       @QueryParam("timestamp") Long timestamp, | ||||
| 									  @QueryParam("level") Integer accountLevel) { | ||||
| 		try { | ||||
| 			if (timestamp == null) { | ||||
| 				timestamp = NTP.getTime(); | ||||
| 			} | ||||
|  | ||||
| 			Constructor<?> constructor = txType.constructor; | ||||
| 			Transaction transaction = (Transaction) constructor.newInstance(null, null); | ||||
| 			// FUTURE: add accountLevel parameter to transaction.getUnitFee() if needed | ||||
| 			return transaction.getUnitFee(timestamp); | ||||
|  | ||||
| 		} catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/fee") | ||||
| 	@Operation( | ||||
| 			summary = "Get recommended fee for supplied transaction data", | ||||
| 			requestBody = @RequestBody( | ||||
| 					required = true, | ||||
| 					content = @Content( | ||||
| 							mediaType = MediaType.TEXT_PLAIN, | ||||
| 							schema = @Schema( | ||||
| 									type = "string" | ||||
| 							) | ||||
| 					) | ||||
| 			) | ||||
| 	) | ||||
| 	@ApiErrors({ | ||||
| 			ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE | ||||
| 	}) | ||||
| 	public long getRecommendedTransactionFee(String rawInputBytes58) { | ||||
| 		byte[] rawInputBytes = Base58.decode(rawInputBytes58); | ||||
| 		if (rawInputBytes.length == 0) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.JSON); | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|  | ||||
| 			// Append null signature on the end before transformation | ||||
| 			byte[] rawBytes = Bytes.concat(rawInputBytes, new byte[TransactionTransformer.SIGNATURE_LENGTH]); | ||||
|  | ||||
| 			TransactionData transactionData = TransactionTransformer.fromBytes(rawBytes); | ||||
| 			if (transactionData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); | ||||
|  | ||||
| 			Transaction transaction = Transaction.fromData(repository, transactionData); | ||||
| 			return transaction.calcRecommendedFee(); | ||||
|  | ||||
| 		} catch (DataException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); | ||||
| 		}  catch (TransformationException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@GET | ||||
| 	@Path("/creator/{publickey}") | ||||
| 	@Operation( | ||||
| @@ -418,32 +498,83 @@ public class TransactionsResource { | ||||
| 	} | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/sign") | ||||
| 	@Path("/convert") | ||||
| 	@Operation( | ||||
| 		summary = "Sign a raw, unsigned transaction", | ||||
| 		requestBody = @RequestBody( | ||||
| 			required = true, | ||||
| 			content = @Content( | ||||
| 				mediaType = MediaType.APPLICATION_JSON, | ||||
| 				schema = @Schema( | ||||
| 					implementation = SimpleTransactionSignRequest.class | ||||
| 				) | ||||
| 			) | ||||
| 		), | ||||
| 		responses = { | ||||
| 			@ApiResponse( | ||||
| 				description = "raw, signed transaction encoded in Base58", | ||||
| 				content = @Content( | ||||
| 					mediaType = MediaType.TEXT_PLAIN, | ||||
| 					schema = @Schema( | ||||
| 						type = "string" | ||||
| 			summary = "Convert transaction bytes into bytes for signing", | ||||
| 			requestBody = @RequestBody( | ||||
| 					required = true, | ||||
| 					content = @Content( | ||||
| 							mediaType = MediaType.TEXT_PLAIN, | ||||
| 							schema = @Schema( | ||||
| 									type = "string", | ||||
| 									description = "raw, unsigned transaction in base58 encoding", | ||||
| 									example = "raw transaction base58" | ||||
| 							) | ||||
| 					) | ||||
| 				) | ||||
| 			) | ||||
| 		} | ||||
| 			), | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							description = "raw, unsigned transaction encoded in Base58, ready for signing", | ||||
| 							content = @Content( | ||||
| 									mediaType = MediaType.TEXT_PLAIN, | ||||
| 									schema = @Schema( | ||||
| 											type = "string" | ||||
| 									) | ||||
| 							) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@ApiErrors({ | ||||
| 		ApiError.NON_PRODUCTION, ApiError.INVALID_PRIVATE_KEY, ApiError.TRANSFORMATION_ERROR | ||||
| 			ApiError.NON_PRODUCTION, ApiError.TRANSFORMATION_ERROR | ||||
| 	}) | ||||
| 	public String convertTransactionForSigning(String rawInputBytes58) { | ||||
| 		byte[] rawInputBytes = Base58.decode(rawInputBytes58); | ||||
| 		if (rawInputBytes.length == 0) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.JSON); | ||||
|  | ||||
| 		try { | ||||
| 			// Append null signature on the end before transformation | ||||
| 			byte[] rawBytes = Bytes.concat(rawInputBytes, new byte[TransactionTransformer.SIGNATURE_LENGTH]); | ||||
|  | ||||
| 			TransactionData transactionData = TransactionTransformer.fromBytes(rawBytes); | ||||
| 			if (transactionData == null) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); | ||||
|  | ||||
| 			byte[] convertedBytes = TransactionTransformer.toBytesForSigning(transactionData); | ||||
|  | ||||
| 			return Base58.encode(convertedBytes); | ||||
| 		} catch (TransformationException e) { | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/sign") | ||||
| 	@Operation( | ||||
| 			summary = "Sign a raw, unsigned transaction", | ||||
| 			requestBody = @RequestBody( | ||||
| 					required = true, | ||||
| 					content = @Content( | ||||
| 							mediaType = MediaType.APPLICATION_JSON, | ||||
| 							schema = @Schema( | ||||
| 									implementation = SimpleTransactionSignRequest.class | ||||
| 							) | ||||
| 					) | ||||
| 			), | ||||
| 			responses = { | ||||
| 					@ApiResponse( | ||||
| 							description = "raw, signed transaction encoded in Base58", | ||||
| 							content = @Content( | ||||
| 									mediaType = MediaType.TEXT_PLAIN, | ||||
| 									schema = @Schema( | ||||
| 											type = "string" | ||||
| 									) | ||||
| 							) | ||||
| 					) | ||||
| 			} | ||||
| 	) | ||||
| 	@ApiErrors({ | ||||
| 			ApiError.NON_PRODUCTION, ApiError.INVALID_PRIVATE_KEY, ApiError.TRANSFORMATION_ERROR | ||||
| 	}) | ||||
| 	public String signTransaction(SimpleTransactionSignRequest signRequest) { | ||||
| 		if (Settings.getInstance().isApiRestricted()) | ||||
|   | ||||
| @@ -33,7 +33,6 @@ import org.qortal.transaction.Transaction.TransactionType; | ||||
| import org.qortal.transform.Transformer; | ||||
| import org.qortal.transform.transaction.TransactionTransformer; | ||||
| import org.qortal.transform.transaction.TransactionTransformer.Transformation; | ||||
| import org.qortal.utils.BIP39; | ||||
| import org.qortal.utils.Base58; | ||||
|  | ||||
| import com.google.common.hash.HashCode; | ||||
| @@ -195,123 +194,6 @@ public class UtilsResource { | ||||
| 		return Base58.encode(random); | ||||
| 	} | ||||
|  | ||||
| 	@GET | ||||
| 	@Path("/mnemonic") | ||||
| 	@Operation( | ||||
| 		summary = "Generate 12-word BIP39 mnemonic", | ||||
| 		description = "Optionally pass 16-byte, base58-encoded entropy or entropy will be internally generated.<br>" | ||||
| 				+ "Example entropy input: YcVfxkQb6JRzqk5kF2tNLv", | ||||
| 		responses = { | ||||
| 			@ApiResponse( | ||||
| 				description = "mnemonic", | ||||
| 				content = @Content( | ||||
| 					mediaType = MediaType.TEXT_PLAIN, | ||||
| 					schema = @Schema( | ||||
| 						type = "string" | ||||
| 					) | ||||
| 				) | ||||
| 			) | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.NON_PRODUCTION, ApiError.INVALID_DATA}) | ||||
| 	public String getMnemonic(@QueryParam("entropy") String suppliedEntropy) { | ||||
| 		if (Settings.getInstance().isApiRestricted()) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION); | ||||
|  | ||||
| 		/* | ||||
| 		 * BIP39 word lists have 2048 entries so can be represented by 11 bits. | ||||
| 		 * UUID (128bits) and another 4 bits gives 132 bits. | ||||
| 		 * 132 bits, divided by 11, gives 12 words. | ||||
| 		 */ | ||||
| 		byte[] entropy; | ||||
| 		if (suppliedEntropy != null) { | ||||
| 			// Use caller-supplied entropy input | ||||
| 			try { | ||||
| 				entropy = Base58.decode(suppliedEntropy); | ||||
| 			} catch (NumberFormatException e) { | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); | ||||
| 			} | ||||
|  | ||||
| 			// Must be 16-bytes | ||||
| 			if (entropy.length != 16) | ||||
| 				throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); | ||||
| 		} else { | ||||
| 			// Generate entropy internally | ||||
| 			UUID uuid = UUID.randomUUID(); | ||||
|  | ||||
| 			byte[] uuidMSB = Longs.toByteArray(uuid.getMostSignificantBits()); | ||||
| 			byte[] uuidLSB = Longs.toByteArray(uuid.getLeastSignificantBits()); | ||||
| 			entropy = Bytes.concat(uuidMSB, uuidLSB); | ||||
| 		} | ||||
|  | ||||
| 		// Use SHA256 to generate more bits | ||||
| 		byte[] hash = Crypto.digest(entropy); | ||||
|  | ||||
| 		// Append first 4 bits from hash to end. (Actually 8 bits but we only use 4). | ||||
| 		byte checksum = (byte) (hash[0] & 0xf0); | ||||
| 		entropy = Bytes.concat(entropy, new byte[] { | ||||
| 			checksum | ||||
| 		}); | ||||
|  | ||||
| 		return BIP39.encode(entropy, "en"); | ||||
| 	} | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/mnemonic") | ||||
| 	@Operation( | ||||
| 		summary = "Calculate binary entropy from 12-word BIP39 mnemonic", | ||||
| 		description = "Returns the base58-encoded binary form, or \"false\" if mnemonic is invalid.", | ||||
| 		requestBody = @RequestBody( | ||||
| 			required = true, | ||||
| 			content = @Content( | ||||
| 				mediaType = MediaType.TEXT_PLAIN, | ||||
| 				schema = @Schema( | ||||
| 					type = "string" | ||||
| 				) | ||||
| 			) | ||||
| 		), | ||||
| 		responses = { | ||||
| 			@ApiResponse( | ||||
| 				description = "entropy in base58", | ||||
| 				content = @Content( | ||||
| 					mediaType = MediaType.TEXT_PLAIN, | ||||
| 					schema = @Schema( | ||||
| 						type = "string" | ||||
| 					) | ||||
| 				) | ||||
| 			) | ||||
| 		} | ||||
| 	) | ||||
| 	@ApiErrors({ApiError.NON_PRODUCTION}) | ||||
| 	public String fromMnemonic(String mnemonic) { | ||||
| 		if (Settings.getInstance().isApiRestricted()) | ||||
| 			throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION); | ||||
|  | ||||
| 		if (mnemonic.isEmpty()) | ||||
| 			return "false"; | ||||
|  | ||||
| 		// Strip leading/trailing whitespace if any | ||||
| 		mnemonic = mnemonic.trim(); | ||||
|  | ||||
| 		String[] phraseWords = mnemonic.split(" "); | ||||
| 		if (phraseWords.length != 12) | ||||
| 			return "false"; | ||||
|  | ||||
| 		// Convert BIP39 mnemonic to binary | ||||
| 		byte[] binary = BIP39.decode(phraseWords, "en"); | ||||
| 		if (binary == null) | ||||
| 			return "false"; | ||||
|  | ||||
| 		byte[] entropy = Arrays.copyOf(binary, 16); // 132 bits is 16.5 bytes, but we're discarding checksum nybble | ||||
|  | ||||
| 		byte checksumNybble = (byte) (binary[16] & 0xf0); | ||||
| 		byte[] checksum = Crypto.digest(entropy); | ||||
| 		if (checksumNybble != (byte) (checksum[0] & 0xf0)) | ||||
| 			return "false"; | ||||
|  | ||||
| 		return Base58.encode(entropy); | ||||
| 	} | ||||
|  | ||||
| 	@POST | ||||
| 	@Path("/privatekey") | ||||
| 	@Operation( | ||||
|   | ||||
| @@ -115,6 +115,9 @@ public class ChatMessagesWebSocket extends ApiWebSocket { | ||||
| 	} | ||||
|  | ||||
| 	private void onNotify(Session session, ChatTransactionData chatTransactionData, List<String> involvingAddresses) { | ||||
| 		if (chatTransactionData == null) | ||||
| 			return; | ||||
|  | ||||
| 		// We only want direct/non-group messages where sender/recipient match our addresses | ||||
| 		String recipient = chatTransactionData.getRecipient(); | ||||
| 		if (recipient == null) | ||||
|   | ||||
| @@ -0,0 +1,76 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| 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.NTP; | ||||
|  | ||||
| import java.io.IOException; | ||||
|  | ||||
| public class ArbitraryDataBuildQueueItem extends ArbitraryDataResource { | ||||
|  | ||||
|     private final Long creationTimestamp; | ||||
|     private Long buildStartTimestamp = null; | ||||
|     private Long buildEndTimestamp = null; | ||||
|     private boolean failed = false; | ||||
|  | ||||
|     /* The maximum amount of time to spend on a single build */ | ||||
|     // TODO: interrupt an in-progress build | ||||
|     public static long BUILD_TIMEOUT = 60*1000L; // 60 seconds | ||||
|     /* The amount of time to remember that a build has failed, to avoid retries */ | ||||
|     public static long FAILURE_TIMEOUT = 5*60*1000L; // 5 minutes | ||||
|  | ||||
|     public ArbitraryDataBuildQueueItem(String resourceId, ResourceIdType resourceIdType, Service service, String identifier) { | ||||
|         super(resourceId, resourceIdType, service, identifier); | ||||
|  | ||||
|         this.creationTimestamp = NTP.getTime(); | ||||
|     } | ||||
|  | ||||
|     public void build() throws IOException, DataException, MissingDataException { | ||||
|         Long now = NTP.getTime(); | ||||
|         if (now == null) { | ||||
|             throw new DataException("NTP time hasn't synced yet"); | ||||
|         } | ||||
|  | ||||
|         this.buildStartTimestamp = now; | ||||
|         ArbitraryDataReader arbitraryDataReader = | ||||
|                 new ArbitraryDataReader(this.resourceId, this.resourceIdType, this.service, this.identifier); | ||||
|  | ||||
|         try { | ||||
|             arbitraryDataReader.loadSynchronously(true); | ||||
|         } finally { | ||||
|             this.buildEndTimestamp = NTP.getTime(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public boolean isBuilding() { | ||||
|         return this.buildStartTimestamp != null; | ||||
|     } | ||||
|  | ||||
|     public boolean isQueued() { | ||||
|         return this.buildStartTimestamp == null; | ||||
|     } | ||||
|  | ||||
|     public boolean hasReachedBuildTimeout(Long now) { | ||||
|         if (now == null || this.creationTimestamp == null) { | ||||
|             return true; | ||||
|         } | ||||
|         return now - this.creationTimestamp > BUILD_TIMEOUT; | ||||
|     } | ||||
|  | ||||
|     public boolean hasReachedFailureTimeout(Long now) { | ||||
|         if (now == null || this.buildStartTimestamp == null) { | ||||
|             return true; | ||||
|         } | ||||
|         return now - this.buildStartTimestamp > FAILURE_TIMEOUT; | ||||
|     } | ||||
|  | ||||
|     public Long getBuildStartTimestamp() { | ||||
|         return this.buildStartTimestamp; | ||||
|     } | ||||
|  | ||||
|     public void setFailed(boolean failed) { | ||||
|         this.failed = failed; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										280
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										280
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,280 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.arbitrary.exception.MissingDataException; | ||||
| import org.qortal.arbitrary.metadata.ArbitraryDataMetadataCache; | ||||
| import org.qortal.arbitrary.misc.Service; | ||||
| import org.qortal.data.transaction.ArbitraryTransactionData; | ||||
| import org.qortal.data.transaction.ArbitraryTransactionData.Method; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.RepositoryManager; | ||||
| import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType; | ||||
| import org.qortal.settings.Settings; | ||||
| import org.qortal.utils.Base58; | ||||
| import org.qortal.utils.NTP; | ||||
|  | ||||
| import java.io.IOException; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.Path; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.List; | ||||
|  | ||||
| public class ArbitraryDataBuilder { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataBuilder.class); | ||||
|  | ||||
|     private final String name; | ||||
|     private final Service service; | ||||
|     private final String identifier; | ||||
|  | ||||
|     private boolean canRequestMissingFiles; | ||||
|  | ||||
|     private List<ArbitraryTransactionData> transactions; | ||||
|     private ArbitraryTransactionData latestPutTransaction; | ||||
|     private final List<Path> paths; | ||||
|     private byte[] latestSignature; | ||||
|     private Path finalPath; | ||||
|     private int layerCount; | ||||
|  | ||||
|     public ArbitraryDataBuilder(String name, Service service, String identifier) { | ||||
|         this.name = name; | ||||
|         this.service = service; | ||||
|         this.identifier = identifier; | ||||
|         this.paths = new ArrayList<>(); | ||||
|  | ||||
|         // By default we can request missing files | ||||
|         // Callers can use setCanRequestMissingFiles(false) to prevent it | ||||
|         this.canRequestMissingFiles = true; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 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(); | ||||
|         this.validateTransactions(); | ||||
|         this.processTransactions(); | ||||
|         this.validatePaths(); | ||||
|         this.findLatestSignature(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Build the latest state of a given resource | ||||
|      * | ||||
|      * @throws DataException | ||||
|      * @throws IOException | ||||
|      * @throws MissingDataException | ||||
|      */ | ||||
|     public void build() throws DataException, IOException, MissingDataException { | ||||
|         this.process(); | ||||
|         this.buildLatestState(); | ||||
|         this.cacheLatestSignature(); | ||||
|     } | ||||
|  | ||||
|     private void fetchTransactions() throws DataException { | ||||
|         try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|  | ||||
|             // Get the most recent PUT | ||||
|             ArbitraryTransactionData latestPut = repository.getArbitraryRepository() | ||||
|                     .getLatestTransaction(this.name, this.service, Method.PUT, this.identifier); | ||||
|             if (latestPut == null) { | ||||
|                 String message = String.format("Couldn't find PUT transaction for name %s, service %s and identifier %s", | ||||
|                         this.name, this.service, this.identifierString()); | ||||
|                 throw new DataException(message); | ||||
|             } | ||||
|             this.latestPutTransaction = latestPut; | ||||
|  | ||||
|             // Load all transactions since the latest PUT | ||||
|             List<ArbitraryTransactionData> transactionDataList = repository.getArbitraryRepository() | ||||
|                     .getArbitraryTransactions(this.name, this.service, this.identifier, latestPut.getTimestamp()); | ||||
|  | ||||
|             this.transactions = transactionDataList; | ||||
|             this.layerCount = transactionDataList.size(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void validateTransactions() throws DataException { | ||||
|         List<ArbitraryTransactionData> transactionDataList = new ArrayList<>(this.transactions); | ||||
|         ArbitraryTransactionData latestPut = this.latestPutTransaction; | ||||
|  | ||||
|         if (latestPut == null) { | ||||
|             throw new DataException("Cannot PATCH without existing PUT. Deploy using PUT first."); | ||||
|         } | ||||
|         if (latestPut.getMethod() != Method.PUT) { | ||||
|             throw new DataException("Expected PUT but received PATCH"); | ||||
|         } | ||||
|         if (transactionDataList.size() == 0) { | ||||
|             throw new DataException(String.format("No transactions found for name %s, service %s, " + | ||||
|                             "identifier: %s, since %d", name, service, this.identifierString(), latestPut.getTimestamp())); | ||||
|         } | ||||
|  | ||||
|         // Verify that the signature of the first transaction matches the latest PUT | ||||
|         ArbitraryTransactionData firstTransaction = transactionDataList.get(0); | ||||
|         if (!Arrays.equals(firstTransaction.getSignature(), latestPut.getSignature())) { | ||||
|             throw new DataException("First transaction did not match latest PUT transaction"); | ||||
|         } | ||||
|  | ||||
|         // Remove the first transaction, as it should be the only PUT | ||||
|         transactionDataList.remove(0); | ||||
|  | ||||
|         for (ArbitraryTransactionData transactionData : transactionDataList) { | ||||
|             if (transactionData == null) { | ||||
|                 throw new DataException("Transaction not found"); | ||||
|             } | ||||
|             if (transactionData.getMethod() != Method.PATCH) { | ||||
|                 throw new DataException("Expected PATCH but received PUT"); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void processTransactions() throws IOException, DataException, MissingDataException { | ||||
|         List<ArbitraryTransactionData> transactionDataList = new ArrayList<>(this.transactions); | ||||
|  | ||||
|         int count = 0; | ||||
|         for (ArbitraryTransactionData transactionData : transactionDataList) { | ||||
|             LOGGER.trace("Found arbitrary transaction {}", Base58.encode(transactionData.getSignature())); | ||||
|             count++; | ||||
|  | ||||
|             // Build the data file, overwriting anything that was previously there | ||||
|             String sig58 = Base58.encode(transactionData.getSignature()); | ||||
|             ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(sig58, ResourceIdType.TRANSACTION_DATA, | ||||
|                     this.service, this.identifier); | ||||
|             arbitraryDataReader.setTransactionData(transactionData); | ||||
|             arbitraryDataReader.setCanRequestMissingFiles(this.canRequestMissingFiles); | ||||
|             boolean hasMissingData = false; | ||||
|             try { | ||||
|                 arbitraryDataReader.loadSynchronously(true); | ||||
|             } | ||||
|             catch (MissingDataException e) { | ||||
|                 hasMissingData = true; | ||||
|             } | ||||
|  | ||||
|             // Handle missing data | ||||
|             if (hasMissingData) { | ||||
|                 if (!this.canRequestMissingFiles) { | ||||
|                     throw new MissingDataException("Files are missing but were not requested."); | ||||
|                 } | ||||
|                 if (count == transactionDataList.size()) { | ||||
|                     // This is the final transaction in the list, so we need to fail | ||||
|                     throw new MissingDataException("Requesting missing files. Please wait and try again."); | ||||
|                 } | ||||
|                 // There are more transactions, so we should process them to give them the opportunity to request data | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             // By this point we should have all data needed to build the layers | ||||
|             Path path = arbitraryDataReader.getFilePath(); | ||||
|             if (path == null) { | ||||
|                 throw new DataException(String.format("Null path when building data from transaction %s", sig58)); | ||||
|             } | ||||
|             if (!Files.exists(path)) { | ||||
|                 throw new DataException(String.format("Path doesn't exist when building data from transaction %s", sig58)); | ||||
|             } | ||||
|             paths.add(path); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void findLatestSignature() throws DataException { | ||||
|         if (this.transactions.size() == 0) { | ||||
|             throw new DataException("Unable to find latest signature from empty transaction list"); | ||||
|         } | ||||
|  | ||||
|         // Find the latest signature | ||||
|         ArbitraryTransactionData latestTransaction = this.transactions.get(this.transactions.size() - 1); | ||||
|         if (latestTransaction == null) { | ||||
|             throw new DataException("Unable to find latest signature from null transaction"); | ||||
|         } | ||||
|  | ||||
|         this.latestSignature = latestTransaction.getSignature(); | ||||
|     } | ||||
|  | ||||
|     private void validatePaths() throws DataException { | ||||
|         if (this.paths.isEmpty()) { | ||||
|             throw new DataException("No paths available from which to build latest state"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void buildLatestState() throws IOException, DataException { | ||||
|         if (this.paths.size() == 1) { | ||||
|             // No patching needed | ||||
|             this.finalPath = this.paths.get(0); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         Path pathBefore = this.paths.get(0); | ||||
|         boolean validateAllLayers = Settings.getInstance().shouldValidateAllDataLayers(); | ||||
|  | ||||
|         // Loop from the second path onwards | ||||
|         for (int i=1; i<paths.size(); i++) { | ||||
|             String identifierPrefix = this.identifier != null ? String.format("[%s]", this.identifier) : ""; | ||||
|             LOGGER.debug(String.format("[%s][%s]%s Applying layer %d...", this.service, this.name, identifierPrefix, i)); | ||||
|  | ||||
|             // Create an instance of ArbitraryDataCombiner | ||||
|             Path pathAfter = this.paths.get(i); | ||||
|             byte[] signatureBefore = this.transactions.get(i-1).getSignature(); | ||||
|             ArbitraryDataCombiner combiner = new ArbitraryDataCombiner(pathBefore, pathAfter, signatureBefore); | ||||
|  | ||||
|             // We only want to validate this layer's hash if it's the final layer, or if the settings | ||||
|             // indicate that we should validate interim layers too | ||||
|             boolean isFinalLayer = (i == paths.size() - 1); | ||||
|             combiner.setShouldValidateHashes(isFinalLayer || validateAllLayers); | ||||
|  | ||||
|             // Now combine this layer with the last, and set the output path to the "before" path for the next cycle | ||||
|             combiner.combine(); | ||||
|             combiner.cleanup(); | ||||
|             pathBefore = combiner.getFinalPath(); | ||||
|         } | ||||
|         this.finalPath = pathBefore; | ||||
|     } | ||||
|  | ||||
|     private void cacheLatestSignature() throws IOException, DataException { | ||||
|         byte[] latestTransactionSignature = this.transactions.get(this.transactions.size()-1).getSignature(); | ||||
|         if (latestTransactionSignature == null) { | ||||
|             throw new DataException("Missing latest transaction signature"); | ||||
|         } | ||||
|         Long now = NTP.getTime(); | ||||
|         if (now == null) { | ||||
|             throw new DataException("NTP time not synced yet"); | ||||
|         } | ||||
|  | ||||
|         ArbitraryDataMetadataCache cache = new ArbitraryDataMetadataCache(this.finalPath); | ||||
|         cache.setSignature(latestTransactionSignature); | ||||
|         cache.setTimestamp(NTP.getTime()); | ||||
|         cache.write(); | ||||
|     } | ||||
|  | ||||
|     private String identifierString() { | ||||
|         return identifier != null ? identifier : ""; | ||||
|     } | ||||
|  | ||||
|     public Path getFinalPath() { | ||||
|         return this.finalPath; | ||||
|     } | ||||
|  | ||||
|     public byte[] getLatestSignature() { | ||||
|         return this.latestSignature; | ||||
|     } | ||||
|  | ||||
|     public int getLayerCount() { | ||||
|         return this.layerCount; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Use the below setter to ensure that we only read existing | ||||
|      * data without requesting any missing files, | ||||
|      * | ||||
|      * @param canRequestMissingFiles | ||||
|      */ | ||||
|     public void setCanRequestMissingFiles(boolean canRequestMissingFiles) { | ||||
|         this.canRequestMissingFiles = canRequestMissingFiles; | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										166
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataCache.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataCache.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,166 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| import org.qortal.arbitrary.ArbitraryDataFile.*; | ||||
| import org.qortal.arbitrary.metadata.ArbitraryDataMetadataCache; | ||||
| import org.qortal.arbitrary.misc.Service; | ||||
| import org.qortal.controller.arbitrary.ArbitraryDataManager; | ||||
| import org.qortal.data.transaction.ArbitraryTransactionData; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.RepositoryManager; | ||||
| import org.qortal.utils.FilesystemUtils; | ||||
|  | ||||
| import java.io.IOException; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.Path; | ||||
| import java.util.Arrays; | ||||
|  | ||||
| public class ArbitraryDataCache { | ||||
|  | ||||
|     private final boolean overwrite; | ||||
|     private final Path filePath; | ||||
|     private final String resourceId; | ||||
|     private final ResourceIdType resourceIdType; | ||||
|     private final Service service; | ||||
|     private final String identifier; | ||||
|  | ||||
|     public ArbitraryDataCache(Path filePath, boolean overwrite, String resourceId, | ||||
|                               ResourceIdType resourceIdType, Service service, String identifier) { | ||||
|         this.filePath = filePath; | ||||
|         this.overwrite = overwrite; | ||||
|         this.resourceId = resourceId; | ||||
|         this.resourceIdType = resourceIdType; | ||||
|         this.service = service; | ||||
|         this.identifier = identifier; | ||||
|     } | ||||
|  | ||||
|     public boolean isCachedDataAvailable() { | ||||
|         return !this.shouldInvalidate(); | ||||
|     } | ||||
|  | ||||
|     public boolean shouldInvalidate() { | ||||
|         try { | ||||
|             // If the user has requested an overwrite, always invalidate the cache | ||||
|             if (this.overwrite) { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             // Overwrite is false, but we still need to invalidate if no files exist | ||||
|             if (!Files.exists(this.filePath) || FilesystemUtils.isDirectoryEmpty(this.filePath)) { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             // We might want to overwrite anyway, if an updated version is available | ||||
|             if (this.shouldInvalidateResource()) { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|         } catch (IOException e) { | ||||
|             // Something went wrong, so invalidate the cache just in case | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         // No need to invalidate the cache | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     private boolean shouldInvalidateResource() { | ||||
|         switch (this.resourceIdType) { | ||||
|  | ||||
|             case NAME: | ||||
|                 return this.shouldInvalidateName(); | ||||
|  | ||||
|             default: | ||||
|                 // Other resource ID types remain constant, so no need to invalidate | ||||
|                 return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private boolean shouldInvalidateName() { | ||||
|         // To avoid spamming the database too often, we shouldn't check sigs or invalidate when rate limited | ||||
|         if (this.rateLimitInEffect()) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // If the state's sig doesn't match the latest transaction's sig, we need to invalidate | ||||
|         // This means that an updated layer is available | ||||
|         if (this.shouldInvalidateDueToSignatureMismatch()) { | ||||
|  | ||||
|             // Add to the in-memory cache first, so that we won't check again for a while | ||||
|             ArbitraryDataManager.getInstance().addResourceToCache(this.getArbitraryDataResource()); | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * rateLimitInEffect() | ||||
|      * | ||||
|      * When loading a website, we need to check the cache for every static asset loaded by the page. | ||||
|      * This would involve asking the database for the latest transaction every time. | ||||
|      * To reduce database load and page load times, we maintain an in-memory list to "rate limit" lookups. | ||||
|      * Once a resource ID is in this in-memory list, we will avoid cache invalidations until it | ||||
|      * has been present in the list for a certain amount of time. | ||||
|      * Items are automatically removed from the list when a new arbitrary transaction arrives, so this | ||||
|      * should not prevent updates from taking effect immediately. | ||||
|      * | ||||
|      * @return whether to avoid lookups for this resource due to the in-memory cache | ||||
|      */ | ||||
|     private boolean rateLimitInEffect() { | ||||
|         return ArbitraryDataManager.getInstance().isResourceCached(this.getArbitraryDataResource()); | ||||
|     } | ||||
|  | ||||
|     private boolean shouldInvalidateDueToSignatureMismatch() { | ||||
|  | ||||
|         // Fetch the latest transaction for this name and service | ||||
|         byte[] latestTransactionSig = this.fetchLatestTransactionSignature(); | ||||
|  | ||||
|         // Now fetch the transaction signature stored in the cache metadata | ||||
|         byte[] cachedSig = this.fetchCachedSignature(); | ||||
|  | ||||
|         // If either are null, we should invalidate | ||||
|         if (latestTransactionSig == null || cachedSig == null) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         // Check if they match | ||||
|         return !Arrays.equals(latestTransactionSig, cachedSig); | ||||
|     } | ||||
|  | ||||
|     private byte[] fetchLatestTransactionSignature() { | ||||
|         try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|  | ||||
|             // Find latest transaction for name and service, with any method | ||||
|             ArbitraryTransactionData latestTransaction = repository.getArbitraryRepository() | ||||
|                     .getLatestTransaction(this.resourceId, this.service, null, this.identifier); | ||||
|  | ||||
|             if (latestTransaction != null) { | ||||
|                 return latestTransaction.getSignature(); | ||||
|             } | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private byte[] fetchCachedSignature() { | ||||
|         try { | ||||
|             // Fetch the transaction signature stored in the cache metadata | ||||
|             ArbitraryDataMetadataCache cache = new ArbitraryDataMetadataCache(this.filePath); | ||||
|             cache.read(); | ||||
|             return cache.getSignature(); | ||||
|  | ||||
|         } catch (IOException | DataException e) { | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private ArbitraryDataResource getArbitraryDataResource() { | ||||
|         // TODO: pass an ArbitraryDataResource into the constructor, rather than individual components | ||||
|         return new ArbitraryDataResource(this.resourceId, this.resourceIdType, this.service, this.identifier); | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										170
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataCombiner.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataCombiner.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,170 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| import org.apache.commons.io.FileUtils; | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.arbitrary.metadata.ArbitraryDataMetadataPatch; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.settings.Settings; | ||||
| import org.qortal.utils.Base58; | ||||
| import org.qortal.utils.FilesystemUtils; | ||||
|  | ||||
| import java.io.File; | ||||
| import java.io.IOException; | ||||
| import java.io.InvalidObjectException; | ||||
| import java.nio.file.DirectoryNotEmptyException; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.Path; | ||||
| import java.util.Arrays; | ||||
|  | ||||
| public class ArbitraryDataCombiner { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataCombiner.class); | ||||
|  | ||||
|     private final Path pathBefore; | ||||
|     private final Path pathAfter; | ||||
|     private final byte[] signatureBefore; | ||||
|     private boolean shouldValidateHashes; | ||||
|     private Path finalPath; | ||||
|     private ArbitraryDataMetadataPatch metadata; | ||||
|  | ||||
|     public ArbitraryDataCombiner(Path pathBefore, Path pathAfter, byte[] signatureBefore) { | ||||
|         this.pathBefore = pathBefore; | ||||
|         this.pathAfter = pathAfter; | ||||
|         this.signatureBefore = signatureBefore; | ||||
|     } | ||||
|  | ||||
|     public void combine() throws IOException, DataException { | ||||
|         try { | ||||
|             this.preExecute(); | ||||
|             this.readMetadata(); | ||||
|             this.validatePreviousSignature(); | ||||
|             this.validatePreviousHash(); | ||||
|             this.process(); | ||||
|             this.validateCurrentHash(); | ||||
|  | ||||
|         } finally { | ||||
|             this.postExecute(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void cleanup() { | ||||
|         this.cleanupPath(this.pathBefore); | ||||
|         this.cleanupPath(this.pathAfter); | ||||
|     } | ||||
|  | ||||
|     private void cleanupPath(Path path) { | ||||
|         // Delete pathBefore, if it exists in our data/temp directory | ||||
|         if (FilesystemUtils.pathInsideDataOrTempPath(path)) { | ||||
|             File directory = new File(path.toString()); | ||||
|             try { | ||||
|                 FileUtils.deleteDirectory(directory); | ||||
|             } catch (IOException e) { | ||||
|                 // This will eventually be cleaned up by a maintenance process, so log the error and continue | ||||
|                 LOGGER.debug("Unable to cleanup directory {}", directory.toString()); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Delete the parent directory of pathBefore if it is empty (and exists in our data/temp directory) | ||||
|         Path parentDirectory = path.getParent(); | ||||
|         if (FilesystemUtils.pathInsideDataOrTempPath(parentDirectory)) { | ||||
|             try { | ||||
|                 Files.deleteIfExists(parentDirectory); | ||||
|             } catch (DirectoryNotEmptyException e) { | ||||
|                 // No need to log anything | ||||
|             } catch (IOException e) { | ||||
|                 // This will eventually be cleaned up by a maintenance process, so log the error and continue | ||||
|                 LOGGER.debug("Unable to cleanup parent directory {}", parentDirectory.toString()); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void preExecute() throws DataException { | ||||
|         if (this.pathBefore == null || this.pathAfter == null) { | ||||
|             throw new DataException("No paths available to build patch"); | ||||
|         } | ||||
|         if (!Files.exists(this.pathBefore) || !Files.exists(this.pathAfter)) { | ||||
|             throw new DataException("Unable to create patch because at least one path doesn't exist"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void postExecute() { | ||||
|  | ||||
|     } | ||||
|  | ||||
|     private void readMetadata() throws IOException, DataException { | ||||
|         this.metadata = new ArbitraryDataMetadataPatch(this.pathAfter); | ||||
|         this.metadata.read(); | ||||
|     } | ||||
|  | ||||
|     private void validatePreviousSignature() throws DataException { | ||||
|         if (this.signatureBefore == null) { | ||||
|             throw new DataException("No previous signature passed to the combiner"); | ||||
|         } | ||||
|  | ||||
|         byte[] previousSignature = this.metadata.getPreviousSignature(); | ||||
|         if (previousSignature == null) { | ||||
|             throw new DataException("Unable to extract previous signature from patch metadata"); | ||||
|         } | ||||
|  | ||||
|         // Compare the signatures | ||||
|         if (!Arrays.equals(previousSignature, this.signatureBefore)) { | ||||
|             throw new DataException("Previous signatures do not match - transactions out of order?"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void validatePreviousHash() throws IOException, DataException { | ||||
|         if (!Settings.getInstance().shouldValidateAllDataLayers()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         byte[] previousHash = this.metadata.getPreviousHash(); | ||||
|         if (previousHash == null) { | ||||
|             throw new DataException("Unable to extract previous hash from patch metadata"); | ||||
|         } | ||||
|  | ||||
|         ArbitraryDataDigest digest = new ArbitraryDataDigest(this.pathBefore); | ||||
|         digest.compute(); | ||||
|         boolean valid = digest.isHashValid(previousHash); | ||||
|         if (!valid) { | ||||
|             String previousHash58 = Base58.encode(previousHash); | ||||
|             throw new InvalidObjectException(String.format("Previous state hash mismatch. " + | ||||
|                     "Patch prevHash: %s, actual: %s", previousHash58, digest.getHash58())); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void process() throws IOException, DataException { | ||||
|         ArbitraryDataMerge merge = new ArbitraryDataMerge(this.pathBefore, this.pathAfter); | ||||
|         merge.compute(); | ||||
|         this.finalPath = merge.getMergePath(); | ||||
|     } | ||||
|  | ||||
|     private void validateCurrentHash() throws IOException, DataException { | ||||
|         if (!this.shouldValidateHashes) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         byte[] currentHash = this.metadata.getCurrentHash(); | ||||
|         if (currentHash == null) { | ||||
|             throw new DataException("Unable to extract current hash from patch metadata"); | ||||
|         } | ||||
|  | ||||
|         ArbitraryDataDigest digest = new ArbitraryDataDigest(this.finalPath); | ||||
|         digest.compute(); | ||||
|         boolean valid = digest.isHashValid(currentHash); | ||||
|         if (!valid) { | ||||
|             String currentHash58 = Base58.encode(currentHash); | ||||
|             throw new InvalidObjectException(String.format("Current state hash mismatch. " + | ||||
|                     "Patch curHash: %s, actual: %s", currentHash58, digest.getHash58())); | ||||
|         } | ||||
| 	} | ||||
|  | ||||
|     public void setShouldValidateHashes(boolean shouldValidateHashes) { | ||||
|         this.shouldValidateHashes = shouldValidateHashes; | ||||
|     } | ||||
|  | ||||
|     public Path getFinalPath() { | ||||
|         return this.finalPath; | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										141
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataCreatePatch.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataCreatePatch.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,141 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.arbitrary.metadata.ArbitraryDataMetadataPatch; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.settings.Settings; | ||||
| import org.qortal.utils.FilesystemUtils; | ||||
|  | ||||
| import java.io.IOException; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.Path; | ||||
| import java.nio.file.Paths; | ||||
| import java.util.UUID; | ||||
|  | ||||
| public class ArbitraryDataCreatePatch { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataCreatePatch.class); | ||||
|  | ||||
|     private final Path pathBefore; | ||||
|     private Path pathAfter; | ||||
|     private final byte[] previousSignature; | ||||
|  | ||||
|     private Path finalPath; | ||||
|     private int totalFileCount; | ||||
|     private int fileDifferencesCount; | ||||
|     private ArbitraryDataMetadataPatch metadata; | ||||
|  | ||||
|     private Path workingPath; | ||||
|     private String identifier; | ||||
|  | ||||
|     public ArbitraryDataCreatePatch(Path pathBefore, Path pathAfter, byte[] previousSignature) { | ||||
|         this.pathBefore = pathBefore; | ||||
|         this.pathAfter = pathAfter; | ||||
|         this.previousSignature = previousSignature; | ||||
|     } | ||||
|  | ||||
|     public void create() throws DataException, IOException { | ||||
|         try { | ||||
|             this.preExecute(); | ||||
|             this.copyFiles(); | ||||
|             this.process(); | ||||
|  | ||||
|         } catch (Exception e) { | ||||
|             this.cleanupOnFailure(); | ||||
|             throw e; | ||||
|  | ||||
|         } finally { | ||||
|             this.postExecute(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void preExecute() throws DataException { | ||||
|         if (this.pathBefore == null || this.pathAfter == null) { | ||||
|             throw new DataException("No paths available to build patch"); | ||||
|         } | ||||
|         if (!Files.exists(this.pathBefore) || !Files.exists(this.pathAfter)) { | ||||
|             throw new DataException("Unable to create patch because at least one path doesn't exist"); | ||||
|         } | ||||
|  | ||||
|         this.createRandomIdentifier(); | ||||
|         this.createWorkingDirectory(); | ||||
|     } | ||||
|  | ||||
|     private void postExecute() { | ||||
|         this.cleanupWorkingPath(); | ||||
|     } | ||||
|  | ||||
|     private void cleanupWorkingPath() { | ||||
|         try { | ||||
|             FilesystemUtils.safeDeleteDirectory(this.workingPath, true); | ||||
|         } catch (IOException e) { | ||||
|             LOGGER.debug("Unable to cleanup working directory"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void cleanupOnFailure() { | ||||
|         try { | ||||
|             FilesystemUtils.safeDeleteDirectory(this.finalPath, true); | ||||
|         } catch (IOException e) { | ||||
|             LOGGER.debug("Unable to cleanup diff directory on failure"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void createRandomIdentifier() { | ||||
|         this.identifier = UUID.randomUUID().toString(); | ||||
|     } | ||||
|  | ||||
|     private void createWorkingDirectory() 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(); | ||||
|         Path tempDir = Paths.get(baseDir, "patch", this.identifier); | ||||
|         try { | ||||
|             Files.createDirectories(tempDir); | ||||
|         } catch (IOException e) { | ||||
|             throw new DataException("Unable to create temp directory"); | ||||
|         } | ||||
|         this.workingPath = tempDir; | ||||
|     } | ||||
|  | ||||
|     private void copyFiles() throws IOException { | ||||
|         // When dealing with single files, we need to copy them to a container directory | ||||
|         // in order for the structure to align with the previous revision and therefore | ||||
|         // make comparisons possible. | ||||
|  | ||||
|         if (this.pathAfter.toFile().isFile()) { | ||||
|             // Create a "data" directory within the working directory | ||||
|             Path workingDataPath = Paths.get(this.workingPath.toString(), "data"); | ||||
|             Files.createDirectories(workingDataPath); | ||||
|             // Copy to temp directory | ||||
|             // Filename is currently hardcoded to "data" | ||||
|             String filename = "data"; //this.pathAfter.getFileName().toString(); | ||||
|             Files.copy(this.pathAfter, Paths.get(workingDataPath.toString(), filename)); | ||||
|             // Update pathAfter to point to the new path | ||||
|             this.pathAfter = workingDataPath; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void process() throws IOException, DataException { | ||||
|  | ||||
|         ArbitraryDataDiff diff = new ArbitraryDataDiff(this.pathBefore, this.pathAfter, this.previousSignature); | ||||
|         this.finalPath = diff.getDiffPath(); | ||||
|         diff.compute(); | ||||
|  | ||||
|         this.totalFileCount = diff.getTotalFileCount(); | ||||
|         this.metadata = diff.getMetadata(); | ||||
|     } | ||||
|  | ||||
|     public Path getFinalPath() { | ||||
|         return this.finalPath; | ||||
|     } | ||||
|  | ||||
|     public int getTotalFileCount() { | ||||
|         return this.totalFileCount; | ||||
|     } | ||||
|  | ||||
|     public ArbitraryDataMetadataPatch getMetadata() { | ||||
|         return this.metadata; | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										383
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataDiff.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										383
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataDiff.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,383 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.json.JSONObject; | ||||
| import org.qortal.arbitrary.metadata.ArbitraryDataMetadataPatch; | ||||
| import org.qortal.arbitrary.patch.UnifiedDiffPatch; | ||||
| import org.qortal.crypto.Crypto; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.settings.Settings; | ||||
|  | ||||
| import java.io.*; | ||||
| import java.nio.file.*; | ||||
| import java.nio.file.attribute.BasicFileAttributes; | ||||
| import java.util.*; | ||||
|  | ||||
|  | ||||
| public class ArbitraryDataDiff { | ||||
|  | ||||
|     /** Only create a patch if both the before and after file sizes are within defined limit **/ | ||||
|     private static final long MAX_DIFF_FILE_SIZE = 100 * 1024L; // 100kiB | ||||
|  | ||||
|  | ||||
|     public enum DiffType { | ||||
|         COMPLETE_FILE, | ||||
|         UNIFIED_DIFF | ||||
|     } | ||||
|  | ||||
|     public static class ModifiedPath { | ||||
|         private Path path; | ||||
|         private DiffType diffType; | ||||
|  | ||||
|         public ModifiedPath(Path path, DiffType diffType) { | ||||
|             this.path = path; | ||||
|             this.diffType = diffType; | ||||
|         } | ||||
|  | ||||
|         public ModifiedPath(JSONObject jsonObject) { | ||||
|             String pathString = jsonObject.getString("path"); | ||||
|             if (pathString != null) { | ||||
|                 this.path = Paths.get(pathString); | ||||
|             } | ||||
|  | ||||
|             String diffTypeString = jsonObject.getString("type"); | ||||
|             if (diffTypeString != null) { | ||||
|                 this.diffType = DiffType.valueOf(diffTypeString); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public Path getPath() { | ||||
|             return this.path; | ||||
|         } | ||||
|  | ||||
|         public DiffType getDiffType() { | ||||
|             return this.diffType; | ||||
|         } | ||||
|  | ||||
|         public String toString() { | ||||
|             return this.path.toString(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataDiff.class); | ||||
|  | ||||
|     private final Path pathBefore; | ||||
|     private final Path pathAfter; | ||||
|     private final byte[] previousSignature; | ||||
|     private byte[] previousHash; | ||||
|     private byte[] currentHash; | ||||
|     private Path diffPath; | ||||
|     private String identifier; | ||||
|  | ||||
|     private final List<Path> addedPaths; | ||||
|     private final List<ModifiedPath> modifiedPaths; | ||||
|     private final List<Path> removedPaths; | ||||
|  | ||||
|     private int totalFileCount; | ||||
|     private ArbitraryDataMetadataPatch metadata; | ||||
|  | ||||
|     public ArbitraryDataDiff(Path pathBefore, Path pathAfter, byte[] previousSignature) throws DataException { | ||||
|         this.pathBefore = pathBefore; | ||||
|         this.pathAfter = pathAfter; | ||||
|         this.previousSignature = previousSignature; | ||||
|  | ||||
|         this.addedPaths = new ArrayList<>(); | ||||
|         this.modifiedPaths = new ArrayList<>(); | ||||
|         this.removedPaths = new ArrayList<>(); | ||||
|  | ||||
|         this.createRandomIdentifier(); | ||||
|         this.createOutputDirectory(); | ||||
|     } | ||||
|  | ||||
|     public void compute() throws IOException, DataException { | ||||
|         try { | ||||
|             this.preExecute(); | ||||
|             this.hashPreviousState(); | ||||
|             this.findAddedOrModifiedFiles(); | ||||
|             this.findRemovedFiles(); | ||||
|             this.validate(); | ||||
|             this.hashCurrentState(); | ||||
|             this.writeMetadata(); | ||||
|  | ||||
|         } finally { | ||||
|             this.postExecute(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void preExecute() { | ||||
|         LOGGER.debug("Generating diff..."); | ||||
|     } | ||||
|  | ||||
|     private void postExecute() { | ||||
|  | ||||
|     } | ||||
|  | ||||
|     private void createRandomIdentifier() { | ||||
|         this.identifier = UUID.randomUUID().toString(); | ||||
|     } | ||||
|  | ||||
|     private void createOutputDirectory() 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(); | ||||
|         Path tempDir = Paths.get(baseDir, "diff", this.identifier); | ||||
|         try { | ||||
|             Files.createDirectories(tempDir); | ||||
|         } catch (IOException e) { | ||||
|             throw new DataException("Unable to create temp directory"); | ||||
|         } | ||||
|         this.diffPath = tempDir; | ||||
|     } | ||||
|  | ||||
|     private void hashPreviousState() throws IOException, DataException { | ||||
|         ArbitraryDataDigest digest = new ArbitraryDataDigest(this.pathBefore); | ||||
|         digest.compute(); | ||||
|         this.previousHash = digest.getHash(); | ||||
|     } | ||||
|  | ||||
|     private void findAddedOrModifiedFiles() throws IOException { | ||||
|         try { | ||||
|             final Path pathBeforeAbsolute = this.pathBefore.toAbsolutePath(); | ||||
|             final Path pathAfterAbsolute = this.pathAfter.toAbsolutePath(); | ||||
|             final Path diffPathAbsolute = this.diffPath.toAbsolutePath(); | ||||
|             final ArbitraryDataDiff diff = this; | ||||
|  | ||||
|             // Check for additions or modifications | ||||
|             Files.walkFileTree(this.pathAfter, new FileVisitor<>() { | ||||
|  | ||||
|                 @Override | ||||
|                 public FileVisitResult preVisitDirectory(Path after, BasicFileAttributes attrs) { | ||||
|                     return FileVisitResult.CONTINUE; | ||||
|                 } | ||||
|  | ||||
|                 @Override | ||||
|                 public FileVisitResult visitFile(Path afterPathAbsolute, BasicFileAttributes attrs) throws IOException { | ||||
|                     Path afterPathRelative = pathAfterAbsolute.relativize(afterPathAbsolute.toAbsolutePath()); | ||||
|                     Path beforePathAbsolute = pathBeforeAbsolute.resolve(afterPathRelative); | ||||
|  | ||||
|                     if (afterPathRelative.startsWith(".qortal")) { | ||||
|                         // Ignore the .qortal metadata folder | ||||
|                         return FileVisitResult.CONTINUE; | ||||
|                     } | ||||
|  | ||||
|                     boolean wasAdded = false; | ||||
|                     boolean wasModified = false; | ||||
|  | ||||
|                     if (!Files.exists(beforePathAbsolute)) { | ||||
|                         LOGGER.trace("File was added: {}", afterPathRelative.toString()); | ||||
|                         diff.addedPaths.add(afterPathRelative); | ||||
|                         wasAdded = true; | ||||
|                     } | ||||
|                     else if (Files.size(afterPathAbsolute) != Files.size(beforePathAbsolute)) { | ||||
|                         // Check file size first because it's quicker | ||||
|                         LOGGER.trace("File size was modified: {}", afterPathRelative.toString()); | ||||
|                         wasModified = true; | ||||
|                     } | ||||
|                     else if (!Arrays.equals(ArbitraryDataDiff.digestFromPath(afterPathAbsolute), ArbitraryDataDiff.digestFromPath(beforePathAbsolute))) { | ||||
|                         // Check hashes as a last resort | ||||
|                         LOGGER.trace("File contents were modified: {}", afterPathRelative.toString()); | ||||
|                         wasModified = true; | ||||
|                     } | ||||
|  | ||||
|                     if (wasAdded) { | ||||
|                         diff.copyFilePathToBaseDir(afterPathAbsolute, diffPathAbsolute, afterPathRelative); | ||||
|                     } | ||||
|                     if (wasModified) { | ||||
|                         try { | ||||
|                             diff.pathModified(beforePathAbsolute, afterPathAbsolute, afterPathRelative, diffPathAbsolute); | ||||
|                         } catch (DataException e) { | ||||
|                             // We can only throw IOExceptions because we are overriding FileVisitor.visitFile() | ||||
|                             throw new IOException(e); | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     // Keep a tally of the total number of files to help with decision making | ||||
|                     diff.totalFileCount++; | ||||
|  | ||||
|                     return FileVisitResult.CONTINUE; | ||||
|                 } | ||||
|  | ||||
|                 @Override | ||||
|                 public FileVisitResult visitFileFailed(Path file, IOException e){ | ||||
|                     LOGGER.info("File visit failed: {}, error: {}", file.toString(), e.getMessage()); | ||||
|                     // TODO: throw exception? | ||||
|                     return FileVisitResult.TERMINATE; | ||||
|                 } | ||||
|  | ||||
|                 @Override | ||||
|                 public FileVisitResult postVisitDirectory(Path dir, IOException e) { | ||||
|                     return FileVisitResult.CONTINUE; | ||||
|                 } | ||||
|  | ||||
|             }); | ||||
|         } catch (IOException e) { | ||||
|             LOGGER.info("IOException when walking through file tree: {}", e.getMessage()); | ||||
|             throw(e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void findRemovedFiles() throws IOException { | ||||
|         try { | ||||
|             final Path pathBeforeAbsolute = this.pathBefore.toAbsolutePath(); | ||||
|             final Path pathAfterAbsolute = this.pathAfter.toAbsolutePath(); | ||||
|             final ArbitraryDataDiff diff = this; | ||||
|  | ||||
|             // Check for removals | ||||
|             Files.walkFileTree(this.pathBefore, new FileVisitor<>() { | ||||
|  | ||||
|                 @Override | ||||
|                 public FileVisitResult preVisitDirectory(Path before, BasicFileAttributes attrs) { | ||||
|                     Path directoryPathBefore = pathBeforeAbsolute.relativize(before.toAbsolutePath()); | ||||
|                     Path directoryPathAfter = pathAfterAbsolute.resolve(directoryPathBefore); | ||||
|  | ||||
|                     if (directoryPathBefore.startsWith(".qortal")) { | ||||
|                         // Ignore the .qortal metadata folder | ||||
|                         return FileVisitResult.CONTINUE; | ||||
|                     } | ||||
|  | ||||
|                     if (!Files.exists(directoryPathAfter)) { | ||||
|                         LOGGER.trace("Directory was removed: {}", directoryPathAfter.toString()); | ||||
|                         diff.removedPaths.add(directoryPathBefore); | ||||
|                         // TODO: we might need to mark directories differently to files | ||||
|                     } | ||||
|  | ||||
|                     return FileVisitResult.CONTINUE; | ||||
|                 } | ||||
|  | ||||
|                 @Override | ||||
|                 public FileVisitResult visitFile(Path before, BasicFileAttributes attrs) { | ||||
|                     Path filePathBefore = pathBeforeAbsolute.relativize(before.toAbsolutePath()); | ||||
|                     Path filePathAfter = pathAfterAbsolute.resolve(filePathBefore); | ||||
|  | ||||
|                     if (filePathBefore.startsWith(".qortal")) { | ||||
|                         // Ignore the .qortal metadata folder | ||||
|                         return FileVisitResult.CONTINUE; | ||||
|                     } | ||||
|  | ||||
|                     if (!Files.exists(filePathAfter)) { | ||||
|                         LOGGER.trace("File was removed: {}", filePathBefore.toString()); | ||||
|                         diff.removedPaths.add(filePathBefore); | ||||
|                     } | ||||
|  | ||||
|                     // Keep a tally of the total number of files to help with decision making | ||||
|                     diff.totalFileCount++; | ||||
|  | ||||
|                     return FileVisitResult.CONTINUE; | ||||
|                 } | ||||
|  | ||||
|                 @Override | ||||
|                 public FileVisitResult visitFileFailed(Path file, IOException e){ | ||||
|                     LOGGER.info("File visit failed: {}, error: {}", file.toString(), e.getMessage()); | ||||
|                     // TODO: throw exception? | ||||
|                     return FileVisitResult.TERMINATE; | ||||
|                 } | ||||
|  | ||||
|                 @Override | ||||
|                 public FileVisitResult postVisitDirectory(Path dir, IOException e) { | ||||
|                     return FileVisitResult.CONTINUE; | ||||
|                 } | ||||
|  | ||||
|             }); | ||||
|         } catch (IOException e) { | ||||
|             throw new IOException(String.format("IOException when walking through file tree: %s", e.getMessage())); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void validate() throws DataException { | ||||
|         if (this.addedPaths.isEmpty() && this.modifiedPaths.isEmpty() && this.removedPaths.isEmpty()) { | ||||
|             throw new DataException("Current state matches previous state. Nothing to do."); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void hashCurrentState() throws IOException, DataException { | ||||
|         ArbitraryDataDigest digest = new ArbitraryDataDigest(this.pathAfter); | ||||
|         digest.compute(); | ||||
|         this.currentHash = digest.getHash(); | ||||
|     } | ||||
|  | ||||
|     private void writeMetadata() throws IOException, DataException { | ||||
|         ArbitraryDataMetadataPatch metadata = new ArbitraryDataMetadataPatch(this.diffPath); | ||||
|         metadata.setAddedPaths(this.addedPaths); | ||||
|         metadata.setModifiedPaths(this.modifiedPaths); | ||||
|         metadata.setRemovedPaths(this.removedPaths); | ||||
|         metadata.setPreviousSignature(this.previousSignature); | ||||
|         metadata.setPreviousHash(this.previousHash); | ||||
|         metadata.setCurrentHash(this.currentHash); | ||||
|         metadata.write(); | ||||
|         this.metadata = metadata; | ||||
|     } | ||||
|  | ||||
|  | ||||
|     private void pathModified(Path beforePathAbsolute, Path afterPathAbsolute, Path afterPathRelative, | ||||
|                               Path destinationBasePathAbsolute) throws IOException, DataException { | ||||
|  | ||||
|         Path destination = Paths.get(destinationBasePathAbsolute.toString(), afterPathRelative.toString()); | ||||
|         long beforeSize = Files.size(beforePathAbsolute); | ||||
|         long afterSize = Files.size(afterPathAbsolute); | ||||
|         DiffType diffType; | ||||
|  | ||||
|         if (beforeSize > MAX_DIFF_FILE_SIZE || afterSize > MAX_DIFF_FILE_SIZE) { | ||||
|             // Files are large, so don't attempt a diff | ||||
|             this.copyFilePathToBaseDir(afterPathAbsolute, destinationBasePathAbsolute, afterPathRelative); | ||||
|             diffType = DiffType.COMPLETE_FILE; | ||||
|         } | ||||
|         else { | ||||
|             // Attempt to create patch using java-diff-utils | ||||
|             UnifiedDiffPatch unifiedDiffPatch = new UnifiedDiffPatch(beforePathAbsolute, afterPathAbsolute, destination); | ||||
|             unifiedDiffPatch.create(); | ||||
|             if (unifiedDiffPatch.isValid()) { | ||||
|                 diffType = DiffType.UNIFIED_DIFF; | ||||
|             } | ||||
|             else { | ||||
|                 // Diff failed validation, so copy the whole file instead | ||||
|                 this.copyFilePathToBaseDir(afterPathAbsolute, destinationBasePathAbsolute, afterPathRelative); | ||||
|                 diffType = DiffType.COMPLETE_FILE; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         ModifiedPath modifiedPath = new ModifiedPath(afterPathRelative, diffType); | ||||
|         this.modifiedPaths.add(modifiedPath); | ||||
|     } | ||||
|  | ||||
|     private void copyFilePathToBaseDir(Path source, Path base, Path relativePath) throws IOException { | ||||
|         if (!Files.exists(source)) { | ||||
|             throw new IOException(String.format("File not found: %s", source.toString())); | ||||
|         } | ||||
|  | ||||
|         // Ensure parent folders exist in the destination | ||||
|         Path dest = Paths.get(base.toString(), relativePath.toString()); | ||||
|         File file = new File(dest.toString()); | ||||
|         File parent = file.getParentFile(); | ||||
|         if (parent != null) { | ||||
|             parent.mkdirs(); | ||||
|         } | ||||
|  | ||||
|         LOGGER.trace("Copying {} to {}", source, dest); | ||||
|         Files.copy(source, dest, StandardCopyOption.REPLACE_EXISTING); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     public Path getDiffPath() { | ||||
|         return this.diffPath; | ||||
|     } | ||||
|  | ||||
|     public int getTotalFileCount() { | ||||
|         return this.totalFileCount; | ||||
|     } | ||||
|  | ||||
|     public ArbitraryDataMetadataPatch getMetadata() { | ||||
|         return this.metadata; | ||||
|     } | ||||
|  | ||||
|  | ||||
|     // Utils | ||||
|  | ||||
|     private static byte[] digestFromPath(Path path) { | ||||
|         try { | ||||
|             return Crypto.digest(path.toFile()); | ||||
|         } catch (IOException e) { | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										73
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataDigest.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataDigest.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.utils.Base58; | ||||
|  | ||||
| import java.io.IOException; | ||||
| import java.nio.charset.StandardCharsets; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.Path; | ||||
| import java.security.MessageDigest; | ||||
| import java.security.NoSuchAlgorithmException; | ||||
| import java.util.Arrays; | ||||
| import java.util.List; | ||||
| import java.util.stream.Collectors; | ||||
|  | ||||
| public class ArbitraryDataDigest { | ||||
|  | ||||
|     private final Path path; | ||||
|     private byte[] hash; | ||||
|  | ||||
|     public ArbitraryDataDigest(Path path) { | ||||
|         this.path = path; | ||||
|     } | ||||
|  | ||||
|     public void compute() throws IOException, DataException { | ||||
|         List<Path> allPaths = Files.walk(path).filter(Files::isRegularFile).sorted().collect(Collectors.toList()); | ||||
|         Path basePathAbsolute = this.path.toAbsolutePath(); | ||||
|  | ||||
|         MessageDigest sha256; | ||||
|         try { | ||||
|             sha256 = MessageDigest.getInstance("SHA-256"); | ||||
|         } catch (NoSuchAlgorithmException e) { | ||||
|             throw new DataException("SHA-256 hashing algorithm unavailable"); | ||||
|         } | ||||
|  | ||||
|         for (Path path : allPaths) { | ||||
|             // We need to work with paths relative to the base path, to ensure the same hash | ||||
|             // is generated on different systems | ||||
|             Path relativePath = basePathAbsolute.relativize(path.toAbsolutePath()); | ||||
|  | ||||
|             // Exclude Qortal folder since it can be different each time | ||||
|             // We only care about hashing the actual user data | ||||
|             if (relativePath.startsWith(".qortal/")) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             // Hash path | ||||
|             byte[] filePathBytes = relativePath.toString().getBytes(StandardCharsets.UTF_8); | ||||
|             sha256.update(filePathBytes); | ||||
|  | ||||
|             // Hash contents | ||||
|             byte[] fileContent = Files.readAllBytes(path); | ||||
|             sha256.update(fileContent); | ||||
|         } | ||||
|         this.hash = sha256.digest(); | ||||
|     } | ||||
|  | ||||
|     public boolean isHashValid(byte[] hash) { | ||||
|         return Arrays.equals(hash, this.hash); | ||||
|     } | ||||
|  | ||||
|     public byte[] getHash() { | ||||
|         return this.hash; | ||||
|     } | ||||
|  | ||||
|     public String getHash58() { | ||||
|         if (this.hash == null) { | ||||
|             return null; | ||||
|         } | ||||
|         return Base58.encode(this.hash); | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										778
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										778
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,778 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata; | ||||
| import org.qortal.crypto.Crypto; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.settings.Settings; | ||||
| import org.qortal.utils.Base58; | ||||
| import org.qortal.utils.FilesystemUtils; | ||||
|  | ||||
| import java.io.*; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.Path; | ||||
| import java.nio.file.Paths; | ||||
| import java.nio.file.StandardCopyOption; | ||||
| import java.util.*; | ||||
| import java.util.stream.Stream; | ||||
|  | ||||
| import static java.util.Arrays.stream; | ||||
| import static java.util.stream.Collectors.toMap; | ||||
|  | ||||
|  | ||||
| public class ArbitraryDataFile { | ||||
|  | ||||
|     // Validation results | ||||
|     public enum ValidationResult { | ||||
|         OK(1), | ||||
|         FILE_TOO_LARGE(10), | ||||
|         FILE_NOT_FOUND(11); | ||||
|  | ||||
|         public final int value; | ||||
|  | ||||
|         private static final Map<Integer, ArbitraryDataFile.ValidationResult> map = stream(ArbitraryDataFile.ValidationResult.values()).collect(toMap(result -> result.value, result -> result)); | ||||
|  | ||||
|         ValidationResult(int value) { | ||||
|             this.value = value; | ||||
|         } | ||||
|  | ||||
|         public static ArbitraryDataFile.ValidationResult valueOf(int value) { | ||||
|             return map.get(value); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Resource ID types | ||||
|     public enum ResourceIdType { | ||||
|         SIGNATURE, | ||||
|         FILE_HASH, | ||||
|         TRANSACTION_DATA, | ||||
|         NAME | ||||
|     } | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataFile.class); | ||||
|  | ||||
|     public static final long MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MiB | ||||
|     public static final int CHUNK_SIZE = 1 * 1024 * 1024; // 1MiB | ||||
|     public static int SHORT_DIGEST_LENGTH = 8; | ||||
|  | ||||
|     protected Path filePath; | ||||
|     protected String hash58; | ||||
|     protected byte[] signature; | ||||
|     private ArrayList<ArbitraryDataFileChunk> chunks; | ||||
|     private byte[] secret; | ||||
|  | ||||
|     // Metadata | ||||
|     private byte[] metadataHash; | ||||
|     private ArbitraryDataFile metadataFile; | ||||
|     private ArbitraryDataTransactionMetadata metadata; | ||||
|  | ||||
|  | ||||
|     public ArbitraryDataFile() { | ||||
|     } | ||||
|  | ||||
|     public ArbitraryDataFile(String hash58, byte[] signature) throws DataException { | ||||
|         this.createDataDirectory(); | ||||
|         this.filePath = ArbitraryDataFile.getOutputFilePath(hash58, signature, false); | ||||
|         this.chunks = new ArrayList<>(); | ||||
|         this.hash58 = hash58; | ||||
|         this.signature = signature; | ||||
|     } | ||||
|  | ||||
|     public ArbitraryDataFile(byte[] fileContent, byte[] signature) throws DataException { | ||||
|         if (fileContent == null) { | ||||
|             LOGGER.error("fileContent is null"); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         this.hash58 = Base58.encode(Crypto.digest(fileContent)); | ||||
|         this.signature = signature; | ||||
|         LOGGER.trace(String.format("File digest: %s, size: %d bytes", this.hash58, fileContent.length)); | ||||
|  | ||||
|         Path outputFilePath = getOutputFilePath(this.hash58, signature, true); | ||||
|         File outputFile = outputFilePath.toFile(); | ||||
|         try (FileOutputStream outputStream = new FileOutputStream(outputFile)) { | ||||
|             outputStream.write(fileContent); | ||||
|             this.filePath = outputFilePath; | ||||
|             // Verify hash | ||||
|             if (!this.hash58.equals(this.digest58())) { | ||||
|                 LOGGER.error("Hash {} does not match file digest {}", this.hash58, this.digest58()); | ||||
|                 this.delete(); | ||||
|                 throw new DataException("Data file digest validation failed"); | ||||
|             } | ||||
|         } catch (IOException e) { | ||||
|             throw new DataException("Unable to write data to file"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static ArbitraryDataFile fromHash58(String hash58, byte[] signature) throws DataException { | ||||
|         return new ArbitraryDataFile(hash58, signature); | ||||
|     } | ||||
|  | ||||
|     public static ArbitraryDataFile fromHash(byte[] hash, byte[] signature) throws DataException { | ||||
|         return ArbitraryDataFile.fromHash58(Base58.encode(hash), signature); | ||||
|     } | ||||
|  | ||||
|     public static ArbitraryDataFile fromPath(Path path, byte[] signature) { | ||||
|         if (path == null) { | ||||
|             return null; | ||||
|         } | ||||
|         File file = path.toFile(); | ||||
|         if (file.exists()) { | ||||
|             try { | ||||
|                 byte[] digest = Crypto.digest(file); | ||||
|                 ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(digest, signature); | ||||
|  | ||||
|                 // Copy file to data directory if needed | ||||
|                 if (Files.exists(path) && !arbitraryDataFile.isInBaseDirectory(path)) { | ||||
|                     arbitraryDataFile.copyToDataDirectory(path, signature); | ||||
|                 } | ||||
|                 // Or, if it's already in the data directory, we may need to move it | ||||
|                 else if (!path.equals(arbitraryDataFile.getFilePath())) { | ||||
|                     // Wrong path, so relocate (but don't cleanup, as the source folder may still be needed by the caller) | ||||
|                     Path dest = arbitraryDataFile.getFilePath(); | ||||
|                     FilesystemUtils.moveFile(path, dest, false); | ||||
|                 } | ||||
|                 return arbitraryDataFile; | ||||
|  | ||||
|             } catch (IOException | DataException e) { | ||||
|                 LOGGER.error("Couldn't compute digest for ArbitraryDataFile"); | ||||
|             } | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public static ArbitraryDataFile fromFile(File file, byte[] signature) { | ||||
|         return ArbitraryDataFile.fromPath(Paths.get(file.getPath()), signature); | ||||
|     } | ||||
|  | ||||
|     private boolean createDataDirectory() { | ||||
|         // Create the data directory if it doesn't exist | ||||
|         String dataPath = Settings.getInstance().getDataPath(); | ||||
|         Path dataDirectory = Paths.get(dataPath); | ||||
|         try { | ||||
|             Files.createDirectories(dataDirectory); | ||||
|         } catch (IOException e) { | ||||
|             LOGGER.error("Unable to create data directory"); | ||||
|             return false; | ||||
|         } | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private Path copyToDataDirectory(Path sourcePath, byte[] signature) throws DataException { | ||||
|         if (this.hash58 == null || this.filePath == null) { | ||||
|             return null; | ||||
|         } | ||||
|         Path outputFilePath = getOutputFilePath(this.hash58, signature, true); | ||||
|         sourcePath = sourcePath.toAbsolutePath(); | ||||
|         Path destPath = outputFilePath.toAbsolutePath(); | ||||
|         try { | ||||
|             return Files.copy(sourcePath, destPath, StandardCopyOption.REPLACE_EXISTING); | ||||
|         } catch (IOException e) { | ||||
|             throw new DataException(String.format("Unable to copy file %s to data directory %s", sourcePath, destPath)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static Path getOutputFilePath(String hash58, byte[] signature, boolean createDirectories) throws DataException { | ||||
|         Path directory; | ||||
|  | ||||
|         if (hash58 == null) { | ||||
|             return null; | ||||
|         } | ||||
|         if (signature != null) { | ||||
|             // Key by signature | ||||
|             String signature58 = Base58.encode(signature); | ||||
|             String sig58First2Chars = signature58.substring(0, 2).toLowerCase(); | ||||
|             String sig58Next2Chars = signature58.substring(2, 4).toLowerCase(); | ||||
|             directory = Paths.get(Settings.getInstance().getDataPath(), sig58First2Chars, sig58Next2Chars, signature58); | ||||
|         } | ||||
|         else { | ||||
|             // Put files without signatures in a "_misc" directory, and the files will be relocated later | ||||
|             String hash58First2Chars = hash58.substring(0, 2).toLowerCase(); | ||||
|             String hash58Next2Chars = hash58.substring(2, 4).toLowerCase(); | ||||
|             directory = Paths.get(Settings.getInstance().getDataPath(), "_misc", hash58First2Chars, hash58Next2Chars); | ||||
|         } | ||||
|  | ||||
|         if (createDirectories) { | ||||
|             try { | ||||
|                 Files.createDirectories(directory); | ||||
|             } catch (IOException e) { | ||||
|                 throw new DataException("Unable to create data subdirectory"); | ||||
|             } | ||||
|         } | ||||
|         return Paths.get(directory.toString(), hash58); | ||||
|     } | ||||
|  | ||||
|     public ValidationResult isValid() { | ||||
|         try { | ||||
|             // Ensure the file exists on disk | ||||
|             if (!Files.exists(this.filePath)) { | ||||
|                 LOGGER.error("File doesn't exist at path {}", this.filePath); | ||||
|                 return ValidationResult.FILE_NOT_FOUND; | ||||
|             } | ||||
|  | ||||
|             // Validate the file size | ||||
|             long fileSize = Files.size(this.filePath); | ||||
|             if (fileSize > MAX_FILE_SIZE) { | ||||
|                 LOGGER.error(String.format("ArbitraryDataFile is too large: %d bytes (max size: %d bytes)", fileSize, MAX_FILE_SIZE)); | ||||
|                 return ArbitraryDataFile.ValidationResult.FILE_TOO_LARGE; | ||||
|             } | ||||
|  | ||||
|         } catch (IOException e) { | ||||
|             return ValidationResult.FILE_NOT_FOUND; | ||||
|         } | ||||
|  | ||||
|         return ValidationResult.OK; | ||||
|     } | ||||
|  | ||||
|     public void validateFileSize(long expectedSize) throws DataException { | ||||
|         // Verify that we can determine the file's size | ||||
|         long fileSize = 0; | ||||
|         try { | ||||
|             fileSize = Files.size(this.getFilePath()); | ||||
|         } catch (IOException e) { | ||||
|             throw new DataException(String.format("Couldn't get file size for transaction %s", Base58.encode(signature))); | ||||
|         } | ||||
|  | ||||
|         // Ensure the file's size matches the size reported by the transaction | ||||
|         if (fileSize != expectedSize) { | ||||
|             throw new DataException(String.format("File size mismatch for transaction %s", Base58.encode(signature))); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void addChunk(ArbitraryDataFileChunk chunk) { | ||||
|         this.chunks.add(chunk); | ||||
|     } | ||||
|  | ||||
|     private void addChunkHashes(List<byte[]> chunkHashes) throws DataException { | ||||
|         if (chunkHashes == null || chunkHashes.isEmpty()) { | ||||
|             return; | ||||
|         } | ||||
|         for (byte[] chunkHash : chunkHashes) { | ||||
|             ArbitraryDataFileChunk chunk = ArbitraryDataFileChunk.fromHash(chunkHash, this.signature); | ||||
|             this.addChunk(chunk); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public List<byte[]> getChunkHashes() { | ||||
|         List<byte[]> hashes = new ArrayList<>(); | ||||
|         if (this.chunks == null || this.chunks.isEmpty()) { | ||||
|             return hashes; | ||||
|         } | ||||
|  | ||||
|         for (ArbitraryDataFileChunk chunkData : this.chunks) { | ||||
|             hashes.add(chunkData.getHash()); | ||||
|         } | ||||
|  | ||||
|         return hashes; | ||||
|     } | ||||
|  | ||||
|     public int split(int chunkSize) throws DataException { | ||||
|         try { | ||||
|  | ||||
|             File file = this.getFile(); | ||||
|             byte[] buffer = new byte[chunkSize]; | ||||
|             this.chunks = new ArrayList<>(); | ||||
|  | ||||
|             if (file != null) { | ||||
|                 try (FileInputStream fileInputStream = new FileInputStream(file); | ||||
|                      BufferedInputStream bis = new BufferedInputStream(fileInputStream)) { | ||||
|  | ||||
|                     int numberOfBytes; | ||||
|                     while ((numberOfBytes = bis.read(buffer)) > 0) { | ||||
|                         try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { | ||||
|                             out.write(buffer, 0, numberOfBytes); | ||||
|                             out.flush(); | ||||
|  | ||||
|                             ArbitraryDataFileChunk chunk = new ArbitraryDataFileChunk(out.toByteArray(), this.signature); | ||||
|                             ValidationResult validationResult = chunk.isValid(); | ||||
|                             if (validationResult == ValidationResult.OK) { | ||||
|                                 this.chunks.add(chunk); | ||||
|                             } else { | ||||
|                                 throw new DataException(String.format("Chunk %s is invalid", chunk)); | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } catch (Exception e) { | ||||
|             throw new DataException("Unable to split file into chunks"); | ||||
|         } | ||||
|  | ||||
|         return this.chunks.size(); | ||||
|     } | ||||
|  | ||||
|     public boolean join() { | ||||
|         // Ensure we have chunks | ||||
|         if (this.chunks != null && this.chunks.size() > 0) { | ||||
|  | ||||
|             // Create temporary path for joined file | ||||
|             // 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(); | ||||
|             Path tempDir = Paths.get(baseDir, "join"); | ||||
|             try { | ||||
|                 Files.createDirectories(tempDir); | ||||
|             } catch (IOException e) { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             // Join the chunks | ||||
|             Path outputPath = Paths.get(tempDir.toString(), this.chunks.get(0).digest58()); | ||||
|             File outputFile = new File(outputPath.toString()); | ||||
|             try (BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(outputFile))) { | ||||
|                 for (ArbitraryDataFileChunk chunk : this.chunks) { | ||||
|                     File sourceFile = chunk.filePath.toFile(); | ||||
|                     BufferedInputStream in = new BufferedInputStream(new FileInputStream(sourceFile)); | ||||
|                     byte[] buffer = new byte[2048]; | ||||
|                     int inSize; | ||||
|                     while ((inSize = in.read(buffer)) != -1) { | ||||
|                         out.write(buffer, 0, inSize); | ||||
|                     } | ||||
|                     in.close(); | ||||
|                 } | ||||
|                 out.close(); | ||||
|  | ||||
|                 // Copy temporary file to data directory | ||||
|                 this.filePath = this.copyToDataDirectory(outputPath, this.signature); | ||||
|                 if (FilesystemUtils.pathInsideDataOrTempPath(outputPath)) { | ||||
|                     Files.delete(outputPath); | ||||
|                 } | ||||
|  | ||||
|                 return true; | ||||
|             } catch (FileNotFoundException e) { | ||||
|                 return false; | ||||
|             } catch (IOException | DataException e) { | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public boolean delete() { | ||||
|         // Delete the complete file | ||||
|         // ... but only if it's inside the Qortal data or temp directory | ||||
|         if (FilesystemUtils.pathInsideDataOrTempPath(this.filePath)) { | ||||
|             if (Files.exists(this.filePath)) { | ||||
|                 try { | ||||
|                     Files.delete(this.filePath); | ||||
|                     this.cleanupFilesystem(); | ||||
|                     LOGGER.debug("Deleted file {}", this.filePath); | ||||
|                     return true; | ||||
|                 } catch (IOException e) { | ||||
|                     LOGGER.warn("Couldn't delete file at path {}", this.filePath); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public boolean deleteAllChunks() { | ||||
|         boolean success = false; | ||||
|  | ||||
|         // Delete the individual chunks | ||||
|         if (this.chunks != null && this.chunks.size() > 0) { | ||||
|             Iterator iterator = this.chunks.iterator(); | ||||
|             while (iterator.hasNext()) { | ||||
|                 ArbitraryDataFileChunk chunk = (ArbitraryDataFileChunk) iterator.next(); | ||||
|                 success = chunk.delete(); | ||||
|                 iterator.remove(); | ||||
|             } | ||||
|         } | ||||
|         return success; | ||||
|     } | ||||
|  | ||||
|     public boolean deleteMetadata() { | ||||
|         if (this.metadataFile != null && this.metadataFile.exists()) { | ||||
|             return this.metadataFile.delete(); | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public boolean deleteAll() { | ||||
|         // Delete the complete file | ||||
|         boolean fileDeleted = this.delete(); | ||||
|  | ||||
|         // Delete the metadata file | ||||
|         boolean metadataDeleted = this.deleteMetadata(); | ||||
|  | ||||
|         // Delete the individual chunks | ||||
|         boolean chunksDeleted = this.deleteAllChunks(); | ||||
|  | ||||
|         return fileDeleted || metadataDeleted || chunksDeleted; | ||||
|     } | ||||
|  | ||||
|     protected void cleanupFilesystem() throws IOException { | ||||
|         // It is essential that use a separate path reference in this method | ||||
|         // as we don't want to modify this.filePath | ||||
|         Path path = this.filePath; | ||||
|          | ||||
|         FilesystemUtils.safeDeleteEmptyParentDirectories(path); | ||||
|     } | ||||
|  | ||||
|     public byte[] getBytes() { | ||||
|         try { | ||||
|             return Files.readAllBytes(this.filePath); | ||||
|         } catch (IOException e) { | ||||
|             LOGGER.error("Unable to read bytes for file"); | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     /* Helper methods */ | ||||
|  | ||||
|     private boolean isInBaseDirectory(Path filePath) { | ||||
|         Path path = filePath.toAbsolutePath(); | ||||
|         String dataPath = Settings.getInstance().getDataPath(); | ||||
|         String basePath = Paths.get(dataPath).toAbsolutePath().toString(); | ||||
|         return path.startsWith(basePath); | ||||
|     } | ||||
|  | ||||
|     public boolean exists() { | ||||
|         File file = this.filePath.toFile(); | ||||
|         return file.exists(); | ||||
|     } | ||||
|  | ||||
|     public boolean chunkExists(byte[] hash) { | ||||
|         for (ArbitraryDataFileChunk chunk : this.chunks) { | ||||
|             if (Arrays.equals(hash, chunk.getHash())) { | ||||
|                 return chunk.exists(); | ||||
|             } | ||||
|         } | ||||
|         if (Arrays.equals(hash, this.metadataHash)) { | ||||
|             if (this.metadataFile != null) { | ||||
|                 return this.metadataFile.exists(); | ||||
|             } | ||||
|         } | ||||
|         if (Arrays.equals(this.getHash(), hash)) { | ||||
|             return this.exists(); | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public boolean allChunksExist() { | ||||
|         try { | ||||
|             if (this.metadataHash == null) { | ||||
|                 // We don't have any metadata so can't check if we have the chunks | ||||
|                 // Even if this transaction has no chunks, we don't have the file either (already checked above) | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             if (this.metadataFile == null) { | ||||
|                 this.metadataFile = ArbitraryDataFile.fromHash(this.metadataHash, this.signature); | ||||
|             } | ||||
|  | ||||
|             // If the metadata file doesn't exist, we can't check if we have the chunks | ||||
|             if (!metadataFile.getFilePath().toFile().exists()) { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             if (this.metadata == null) { | ||||
|                 this.setMetadata(new ArbitraryDataTransactionMetadata(this.metadataFile.getFilePath())); | ||||
|             } | ||||
|  | ||||
|             // Read the metadata | ||||
|             List<byte[]> chunks = metadata.getChunks(); | ||||
|             for (byte[] chunkHash : chunks) { | ||||
|                 ArbitraryDataFileChunk chunk = ArbitraryDataFileChunk.fromHash(chunkHash, this.signature); | ||||
|                 if (!chunk.exists()) { | ||||
|                     return false; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return true; | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             // Something went wrong, so assume we don't have all the chunks | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public boolean anyChunksExist() throws DataException { | ||||
|         try { | ||||
|             if (this.metadataHash == null) { | ||||
|                 // We don't have any metadata so can't check if we have the chunks | ||||
|                 // Even if this transaction has no chunks, we don't have the file either (already checked above) | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             if (this.metadataFile == null) { | ||||
|                 this.metadataFile = ArbitraryDataFile.fromHash(this.metadataHash, this.signature); | ||||
|             } | ||||
|  | ||||
|             // If the metadata file doesn't exist, we can't check if we have any chunks | ||||
|             if (!metadataFile.getFilePath().toFile().exists()) { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             if (this.metadata == null) { | ||||
|                 this.setMetadata(new ArbitraryDataTransactionMetadata(this.metadataFile.getFilePath())); | ||||
|             } | ||||
|  | ||||
|             // Read the metadata | ||||
|             List<byte[]> chunks = metadata.getChunks(); | ||||
|             for (byte[] chunkHash : chunks) { | ||||
|                 ArbitraryDataFileChunk chunk = ArbitraryDataFileChunk.fromHash(chunkHash, this.signature); | ||||
|                 if (chunk.exists()) { | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return false; | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             // Something went wrong, so assume we don't have all the chunks | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public boolean allFilesExist() { | ||||
|         if (this.exists()) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         // Complete file doesn't exist, so check the chunks | ||||
|         if (this.allChunksExist()) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Retrieve a list of file hashes for this transaction that we do not hold locally | ||||
|      * | ||||
|      * @return a List of chunk hashes, or null if we are unable to determine what is missing | ||||
|      */ | ||||
|     public List<byte[]> missingHashes() { | ||||
|         List<byte[]> missingHashes = new ArrayList<>(); | ||||
|         try { | ||||
|             if (this.metadataHash == null) { | ||||
|                 // We don't have any metadata so can't check if we have the chunks | ||||
|                 // Even if this transaction has no chunks, we don't have the file either (already checked above) | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             if (this.metadataFile == null) { | ||||
|                 this.metadataFile = ArbitraryDataFile.fromHash(this.metadataHash, this.signature); | ||||
|             } | ||||
|  | ||||
|             // If the metadata file doesn't exist, we can't check if we have the chunks | ||||
|             if (!metadataFile.getFilePath().toFile().exists()) { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             if (this.metadata == null) { | ||||
|                 this.setMetadata(new ArbitraryDataTransactionMetadata(this.metadataFile.getFilePath())); | ||||
|             } | ||||
|  | ||||
|             // Read the metadata | ||||
|             List<byte[]> chunks = metadata.getChunks(); | ||||
|             for (byte[] chunkHash : chunks) { | ||||
|                 ArbitraryDataFileChunk chunk = ArbitraryDataFileChunk.fromHash(chunkHash, this.signature); | ||||
|                 if (!chunk.exists()) { | ||||
|                     missingHashes.add(chunkHash); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return missingHashes; | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             // Something went wrong, so we can't make a sensible decision | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public boolean containsChunk(byte[] hash) { | ||||
|         for (ArbitraryDataFileChunk chunk : this.chunks) { | ||||
|             if (Arrays.equals(hash, chunk.getHash())) { | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public long size() { | ||||
|         try { | ||||
|             return Files.size(this.filePath); | ||||
|         } catch (IOException e) { | ||||
|             return 0; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public int chunkCount() { | ||||
|         return this.chunks.size(); | ||||
|     } | ||||
|  | ||||
|     public List<ArbitraryDataFileChunk> getChunks() { | ||||
|         return this.chunks; | ||||
|     } | ||||
|  | ||||
|     public byte[] chunkHashes() throws DataException { | ||||
|         if (this.chunks != null && this.chunks.size() > 0) { | ||||
|             // Return null if we only have one chunk, with the same hash as the parent | ||||
|             if (Arrays.equals(this.digest(), this.chunks.get(0).digest())) { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             try { | ||||
|                 ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); | ||||
|                 for (ArbitraryDataFileChunk chunk : this.chunks) { | ||||
|                     byte[] chunkHash = chunk.digest(); | ||||
|                     if (chunkHash.length != 32) { | ||||
|                         LOGGER.info("Invalid chunk hash length: {}", chunkHash.length); | ||||
|                         throw new DataException("Invalid chunk hash length"); | ||||
|                     } | ||||
|                     outputStream.write(chunk.digest()); | ||||
|                 } | ||||
|                 return outputStream.toByteArray(); | ||||
|             } catch (IOException e) { | ||||
|                 return null; | ||||
|             } | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public List<byte[]> chunkHashList() { | ||||
|         List<byte[]> chunks = new ArrayList<>(); | ||||
|  | ||||
|         if (this.chunks != null && this.chunks.size() > 0) { | ||||
|             // Return null if we only have one chunk, with the same hash as the parent | ||||
|             if (Arrays.equals(this.digest(), this.chunks.get(0).digest())) { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             try { | ||||
|                 for (ArbitraryDataFileChunk chunk : this.chunks) { | ||||
|                     byte[] chunkHash = chunk.digest(); | ||||
|                     if (chunkHash.length != 32) { | ||||
|                         LOGGER.info("Invalid chunk hash length: {}", chunkHash.length); | ||||
|                         throw new DataException("Invalid chunk hash length"); | ||||
|                     } | ||||
|                     chunks.add(chunkHash); | ||||
|                 } | ||||
|                 return chunks; | ||||
|  | ||||
|             } catch (DataException e) { | ||||
|                 return null; | ||||
|             } | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private void loadMetadata() throws DataException { | ||||
|         try { | ||||
|             this.metadata.read(); | ||||
|  | ||||
|         } catch (DataException | IOException e) { | ||||
|             throw new DataException(e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private File getFile() { | ||||
|         File file = this.filePath.toFile(); | ||||
|         if (file.exists()) { | ||||
|             return file; | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public Path getFilePath() { | ||||
|         return this.filePath; | ||||
|     } | ||||
|  | ||||
|     public byte[] digest() { | ||||
|         File file = this.getFile(); | ||||
|         if (file != null && file.exists()) { | ||||
|             try { | ||||
|                 return Crypto.digest(file); | ||||
|  | ||||
|             } catch (IOException e) { | ||||
|                 LOGGER.error("Couldn't compute digest for ArbitraryDataFile"); | ||||
|             } | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public String digest58() { | ||||
|         if (this.digest() != null) { | ||||
|             return Base58.encode(this.digest()); | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public String shortHash58() { | ||||
|         if (this.hash58 == null) { | ||||
|             return null; | ||||
|         } | ||||
|         return this.hash58.substring(0, Math.min(this.hash58.length(), SHORT_DIGEST_LENGTH)); | ||||
|     } | ||||
|  | ||||
|     public String getHash58() { | ||||
|         return this.hash58; | ||||
|     } | ||||
|  | ||||
|     public byte[] getHash() { | ||||
|         return Base58.decode(this.hash58); | ||||
|     } | ||||
|  | ||||
|     public String printChunks() { | ||||
|         String outputString = ""; | ||||
|         if (this.chunkCount() > 0) { | ||||
|             for (ArbitraryDataFileChunk chunk : this.chunks) { | ||||
|                 if (outputString.length() > 0) { | ||||
|                     outputString = outputString.concat(","); | ||||
|                 } | ||||
|                 outputString = outputString.concat(chunk.digest58()); | ||||
|             } | ||||
|         } | ||||
|         return outputString; | ||||
|     } | ||||
|  | ||||
|     public void setSecret(byte[] secret) { | ||||
|         this.secret = secret; | ||||
|     } | ||||
|  | ||||
|     public byte[] getSecret() { | ||||
|         return this.secret; | ||||
|     } | ||||
|  | ||||
|     public byte[] getSignature() { | ||||
|         return this.signature; | ||||
|     } | ||||
|  | ||||
|     public void setMetadataFile(ArbitraryDataFile metadataFile) { | ||||
|         this.metadataFile = metadataFile; | ||||
|     } | ||||
|  | ||||
|     public ArbitraryDataFile getMetadataFile() { | ||||
|         return this.metadataFile; | ||||
|     } | ||||
|  | ||||
|     public void setMetadataHash(byte[] hash) throws DataException { | ||||
|         this.metadataHash = hash; | ||||
|  | ||||
|         if (hash == null) { | ||||
|             return; | ||||
|         } | ||||
|         this.metadataFile = ArbitraryDataFile.fromHash(hash, this.signature); | ||||
|         if (metadataFile.exists()) { | ||||
|             this.setMetadata(new ArbitraryDataTransactionMetadata(this.metadataFile.getFilePath())); | ||||
|             this.addChunkHashes(this.metadata.getChunks()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public byte[] getMetadataHash() { | ||||
|         return this.metadataHash; | ||||
|     } | ||||
|  | ||||
|     public void setMetadata(ArbitraryDataTransactionMetadata metadata) throws DataException { | ||||
|         this.metadata = metadata; | ||||
|         this.loadMetadata(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public String toString() { | ||||
|         return this.shortHash58(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,54 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.utils.Base58; | ||||
|  | ||||
| import java.io.IOException; | ||||
| import java.nio.file.Files; | ||||
|  | ||||
|  | ||||
| public class ArbitraryDataFileChunk extends ArbitraryDataFile { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataFileChunk.class); | ||||
|  | ||||
|     public ArbitraryDataFileChunk(String hash58, byte[] signature) throws DataException { | ||||
|         super(hash58, signature); | ||||
|     } | ||||
|  | ||||
|     public ArbitraryDataFileChunk(byte[] fileContent, byte[] signature) throws DataException { | ||||
|         super(fileContent, signature); | ||||
|     } | ||||
|  | ||||
|     public static ArbitraryDataFileChunk fromHash58(String hash58, byte[] signature) throws DataException { | ||||
|         return new ArbitraryDataFileChunk(hash58, signature); | ||||
|     } | ||||
|  | ||||
|     public static ArbitraryDataFileChunk fromHash(byte[] hash, byte[] signature) throws DataException { | ||||
|         return ArbitraryDataFileChunk.fromHash58(Base58.encode(hash), signature); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public ValidationResult isValid() { | ||||
|         // DataChunk validation applies here too | ||||
|         ValidationResult superclassValidationResult = super.isValid(); | ||||
|         if (superclassValidationResult != ValidationResult.OK) { | ||||
|             return superclassValidationResult; | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             // Validate the file size (chunks have stricter limits) | ||||
|             long fileSize = Files.size(this.filePath); | ||||
|             if (fileSize > CHUNK_SIZE) { | ||||
|                 LOGGER.error(String.format("DataFileChunk is too large: %d bytes (max chunk size: %d bytes)", fileSize, CHUNK_SIZE)); | ||||
|                 return ValidationResult.FILE_TOO_LARGE; | ||||
|             } | ||||
|  | ||||
|         } catch (IOException e) { | ||||
|             return ValidationResult.FILE_NOT_FOUND; | ||||
|         } | ||||
|  | ||||
|         return ValidationResult.OK; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										176
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataMerge.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataMerge.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,176 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| import org.apache.commons.io.FileUtils; | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.arbitrary.ArbitraryDataDiff.*; | ||||
| import org.qortal.arbitrary.metadata.ArbitraryDataMetadataPatch; | ||||
| import org.qortal.arbitrary.patch.UnifiedDiffPatch; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.settings.Settings; | ||||
| import org.qortal.utils.FilesystemUtils; | ||||
|  | ||||
| import java.io.File; | ||||
| import java.io.IOException; | ||||
| import java.nio.file.*; | ||||
| import java.util.List; | ||||
| import java.util.UUID; | ||||
|  | ||||
| public class ArbitraryDataMerge { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataMerge.class); | ||||
|  | ||||
|     private final Path pathBefore; | ||||
|     private final Path pathAfter; | ||||
|     private Path mergePath; | ||||
|     private String identifier; | ||||
|     private ArbitraryDataMetadataPatch metadata; | ||||
|  | ||||
|     public ArbitraryDataMerge(Path pathBefore, Path pathAfter) { | ||||
|         this.pathBefore = pathBefore; | ||||
|         this.pathAfter = pathAfter; | ||||
|     } | ||||
|  | ||||
|     public void compute() throws IOException, DataException { | ||||
|         try { | ||||
|             this.preExecute(); | ||||
|             this.copyPreviousStateToMergePath(); | ||||
|             this.loadMetadata(); | ||||
|             this.applyDifferences(); | ||||
|             this.copyMetadata(); | ||||
|  | ||||
|         } finally { | ||||
|             this.postExecute(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void preExecute() throws DataException { | ||||
|         this.createRandomIdentifier(); | ||||
|         this.createOutputDirectory(); | ||||
|     } | ||||
|  | ||||
|     private void postExecute() { | ||||
|  | ||||
|     } | ||||
|  | ||||
|     private void createRandomIdentifier() { | ||||
|         this.identifier = UUID.randomUUID().toString(); | ||||
|     } | ||||
|  | ||||
|     private void createOutputDirectory() 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(); | ||||
|         Path tempDir = Paths.get(baseDir, "merge", this.identifier); | ||||
|         try { | ||||
|             Files.createDirectories(tempDir); | ||||
|         } catch (IOException e) { | ||||
|             throw new DataException("Unable to create temp directory"); | ||||
|         } | ||||
|         this.mergePath = tempDir; | ||||
|     } | ||||
|  | ||||
|     private void copyPreviousStateToMergePath() throws IOException { | ||||
|         ArbitraryDataMerge.copyDirPathToBaseDir(this.pathBefore, this.mergePath, Paths.get("")); | ||||
|     } | ||||
|  | ||||
|     private void loadMetadata() throws IOException, DataException { | ||||
|         this.metadata = new ArbitraryDataMetadataPatch(this.pathAfter); | ||||
|         this.metadata.read(); | ||||
|     } | ||||
|  | ||||
|     private void applyDifferences() throws IOException, DataException { | ||||
|  | ||||
|         List<Path> addedPaths = this.metadata.getAddedPaths(); | ||||
|         for (Path path : addedPaths) { | ||||
|             LOGGER.trace("File was added: {}", path.toString()); | ||||
|             Path filePath = Paths.get(this.pathAfter.toString(), path.toString()); | ||||
|             ArbitraryDataMerge.copyPathToBaseDir(filePath, this.mergePath, path); | ||||
|         } | ||||
|  | ||||
|         List<ModifiedPath> modifiedPaths = this.metadata.getModifiedPaths(); | ||||
|         for (ModifiedPath modifiedPath : modifiedPaths) { | ||||
|             LOGGER.trace("File was modified: {}", modifiedPath.toString()); | ||||
|             this.applyPatch(modifiedPath); | ||||
|         } | ||||
|  | ||||
|         List<Path> removedPaths = this.metadata.getRemovedPaths(); | ||||
|         for (Path path : removedPaths) { | ||||
|             LOGGER.trace("File was removed: {}", path.toString()); | ||||
|             ArbitraryDataMerge.deletePathInBaseDir(this.mergePath, path); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void applyPatch(ModifiedPath modifiedPath) throws IOException, DataException { | ||||
|         if (modifiedPath.getDiffType() == DiffType.UNIFIED_DIFF) { | ||||
|             // Create destination file from patch | ||||
|             UnifiedDiffPatch unifiedDiffPatch = new UnifiedDiffPatch(pathBefore, pathAfter, mergePath); | ||||
|             unifiedDiffPatch.apply(modifiedPath.getPath()); | ||||
|         } | ||||
|         else if (modifiedPath.getDiffType() == DiffType.COMPLETE_FILE) { | ||||
|             // Copy complete file | ||||
|             Path filePath = Paths.get(this.pathAfter.toString(), modifiedPath.getPath().toString()); | ||||
|             ArbitraryDataMerge.copyPathToBaseDir(filePath, this.mergePath, modifiedPath.getPath()); | ||||
|         } | ||||
|         else { | ||||
|             throw new DataException(String.format("Unrecognized patch diff type: %s", modifiedPath.getDiffType())); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void copyMetadata() throws IOException { | ||||
|         Path filePath = Paths.get(this.pathAfter.toString(), ".qortal"); | ||||
|         ArbitraryDataMerge.copyPathToBaseDir(filePath, this.mergePath, Paths.get(".qortal")); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     private static void copyPathToBaseDir(Path source, Path base, Path relativePath) throws IOException { | ||||
|         if (!Files.exists(source)) { | ||||
|             throw new IOException(String.format("File not found: %s", source.toString())); | ||||
|         } | ||||
|  | ||||
|         File sourceFile = source.toFile(); | ||||
|         Path dest = Paths.get(base.toString(), relativePath.toString()); | ||||
|         LOGGER.trace("Copying {} to {}", source, dest); | ||||
|  | ||||
|         if (sourceFile.isFile()) { | ||||
|             Files.copy(source, dest, StandardCopyOption.REPLACE_EXISTING); | ||||
|         } | ||||
|         else if (sourceFile.isDirectory()) { | ||||
|             FilesystemUtils.copyAndReplaceDirectory(source.toString(), dest.toString()); | ||||
|         } | ||||
|         else { | ||||
|             throw new IOException(String.format("Invalid file: %s", source.toString())); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static void copyDirPathToBaseDir(Path source, Path base, Path relativePath) throws IOException { | ||||
|         if (!Files.exists(source)) { | ||||
|             throw new IOException(String.format("File not found: %s", source.toString())); | ||||
|         } | ||||
|  | ||||
|         Path dest = Paths.get(base.toString(), relativePath.toString()); | ||||
|         LOGGER.trace("Copying {} to {}", source, dest); | ||||
|         FilesystemUtils.copyAndReplaceDirectory(source.toString(), dest.toString()); | ||||
|     } | ||||
|  | ||||
|     private static void deletePathInBaseDir(Path base, Path relativePath) throws IOException { | ||||
|         Path dest = Paths.get(base.toString(), relativePath.toString()); | ||||
|         File file = new File(dest.toString()); | ||||
|         if (file.exists() && file.isFile()) { | ||||
|             if (FilesystemUtils.pathInsideDataOrTempPath(dest)) { | ||||
|                 LOGGER.trace("Deleting file {}", dest); | ||||
|                 Files.delete(dest); | ||||
|             } | ||||
|         } | ||||
|         if (file.exists() && file.isDirectory()) { | ||||
|             if (FilesystemUtils.pathInsideDataOrTempPath(dest)) { | ||||
|                 LOGGER.trace("Deleting directory {}", dest); | ||||
|                 FileUtils.deleteDirectory(file); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public Path getMergePath() { | ||||
|         return this.mergePath; | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										558
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										558
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,558 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| import org.apache.commons.io.FileUtils; | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
|  | ||||
| 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.*; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| 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 javax.crypto.BadPaddingException; | ||||
| import javax.crypto.IllegalBlockSizeException; | ||||
| import javax.crypto.NoSuchPaddingException; | ||||
| import javax.crypto.SecretKey; | ||||
| import javax.crypto.spec.SecretKeySpec; | ||||
| import java.io.File; | ||||
| import java.io.IOException; | ||||
| import java.io.InvalidObjectException; | ||||
| import java.nio.file.*; | ||||
| import java.nio.file.attribute.BasicFileAttributes; | ||||
| import java.security.InvalidAlgorithmParameterException; | ||||
| import java.security.InvalidKeyException; | ||||
| import java.security.NoSuchAlgorithmException; | ||||
| import java.util.Arrays; | ||||
|  | ||||
| public class ArbitraryDataReader { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataReader.class); | ||||
|  | ||||
|     private final String resourceId; | ||||
|     private final ResourceIdType resourceIdType; | ||||
|     private final Service service; | ||||
|     private final String identifier; | ||||
|     private ArbitraryTransactionData transactionData; | ||||
|     private String secret58; | ||||
|     private Path filePath; | ||||
|     private boolean canRequestMissingFiles; | ||||
|  | ||||
|     // Intermediate paths | ||||
|     private final Path workingPath; | ||||
|     private final Path uncompressedPath; | ||||
|  | ||||
|     // Stats (available for synchronous builds only) | ||||
|     private int layerCount; | ||||
|     private byte[] latestSignature; | ||||
|  | ||||
|     public ArbitraryDataReader(String resourceId, ResourceIdType resourceIdType, Service service, String identifier) { | ||||
|         // Ensure names are always lowercase | ||||
|         if (resourceIdType == ResourceIdType.NAME) { | ||||
|             resourceId = resourceId.toLowerCase(); | ||||
|         } | ||||
|  | ||||
|         // If identifier is a blank string, or reserved keyword "default", treat it as null | ||||
|         if (identifier == null || identifier.equals("") || identifier.equals("default")) { | ||||
|             identifier = null; | ||||
|         } | ||||
|  | ||||
|         this.resourceId = resourceId; | ||||
|         this.resourceIdType = resourceIdType; | ||||
|         this.service = service; | ||||
|         this.identifier = identifier; | ||||
|  | ||||
|         this.workingPath = this.buildWorkingPath(); | ||||
|         this.uncompressedPath = Paths.get(this.workingPath.toString(), "data"); | ||||
|  | ||||
|         // By default we can request missing files | ||||
|         // Callers can use setCanRequestMissingFiles(false) to prevent it | ||||
|         this.canRequestMissingFiles = true; | ||||
|     } | ||||
|  | ||||
|     private Path buildWorkingPath() { | ||||
|         // 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); | ||||
|     } | ||||
|  | ||||
|     public boolean isCachedDataAvailable() { | ||||
|         // If this resource is in the build queue then we shouldn't attempt to serve | ||||
|         // cached data, as it may not be fully built | ||||
|         if (ArbitraryDataBuildManager.getInstance().isInBuildQueue(this.createQueueItem())) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // Not in the build queue - so check the cache itself | ||||
|         ArbitraryDataCache cache = new ArbitraryDataCache(this.uncompressedPath, false, | ||||
|                 this.resourceId, this.resourceIdType, this.service, this.identifier); | ||||
|         if (cache.isCachedDataAvailable()) { | ||||
|             this.filePath = this.uncompressedPath; | ||||
|             return true; | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public boolean isBuilding() { | ||||
|         return ArbitraryDataBuildManager.getInstance().isInBuildQueue(this.createQueueItem()); | ||||
|     } | ||||
|  | ||||
|     private ArbitraryDataBuildQueueItem createQueueItem() { | ||||
|         return new ArbitraryDataBuildQueueItem(this.resourceId, this.resourceIdType, this.service, this.identifier); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * loadAsynchronously | ||||
|      * | ||||
|      * Attempts to load the resource asynchronously | ||||
|      * This adds the build task to a queue, and the result will be cached when complete | ||||
|      * To check the status of the build, periodically call isCachedDataAvailable() | ||||
|      * Once it returns true, you can then use getFilePath() to access the data itself. | ||||
|      * | ||||
|      * @param overwrite - set to true to force rebuild an existing cache | ||||
|      * @return true if added or already present in queue; false if not | ||||
|      */ | ||||
|     public boolean loadAsynchronously(boolean overwrite) { | ||||
|         ArbitraryDataCache cache = new ArbitraryDataCache(this.uncompressedPath, overwrite, | ||||
|                 this.resourceId, this.resourceIdType, this.service, this.identifier); | ||||
|         if (cache.isCachedDataAvailable()) { | ||||
|             // Use cached data | ||||
|             this.filePath = this.uncompressedPath; | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return ArbitraryDataBuildManager.getInstance().addToBuildQueue(this.createQueueItem()); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * loadSynchronously | ||||
|      * | ||||
|      * Attempts to load the resource synchronously | ||||
|      * Warning: this can block for a long time when building or fetching complex data | ||||
|      * 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 { | ||||
|             ArbitraryDataCache cache = new ArbitraryDataCache(this.uncompressedPath, overwrite, | ||||
|                     this.resourceId, this.resourceIdType, this.service, this.identifier); | ||||
|             if (cache.isCachedDataAvailable()) { | ||||
|                 // Use cached data | ||||
|                 this.filePath = this.uncompressedPath; | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             this.preExecute(); | ||||
|             this.deleteExistingFiles(); | ||||
|             this.fetch(); | ||||
|             this.decrypt(); | ||||
|             this.uncompress(); | ||||
|             this.validate(); | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             this.deleteWorkingDirectory(); | ||||
|             throw new DataException(e.getMessage()); | ||||
|  | ||||
|         } finally { | ||||
|             this.postExecute(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void preExecute() throws DataException { | ||||
|         ArbitraryDataBuildManager.getInstance().setBuildInProgress(true); | ||||
|         this.checkEnabled(); | ||||
|         this.createWorkingDirectory(); | ||||
|         this.createUncompressedDirectory(); | ||||
|     } | ||||
|  | ||||
|     private void postExecute() { | ||||
|         ArbitraryDataBuildManager.getInstance().setBuildInProgress(false); | ||||
|     } | ||||
|  | ||||
|     private void checkEnabled() throws DataException { | ||||
|         if (!Settings.getInstance().isQdnEnabled()) { | ||||
|             throw new DataException("QDN is disabled in settings"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void createWorkingDirectory() throws DataException { | ||||
|         try { | ||||
|             Files.createDirectories(this.workingPath); | ||||
|         } catch (IOException e) { | ||||
|             throw new DataException("Unable to create temp directory"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 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() throws IOException { | ||||
|         FilesystemUtils.safeDeleteDirectory(this.workingPath, true); | ||||
|     } | ||||
|  | ||||
|     private void createUncompressedDirectory() throws DataException { | ||||
|         try { | ||||
|             // Create parent directory | ||||
|             Files.createDirectories(this.uncompressedPath.getParent()); | ||||
|             // Ensure child directory doesn't already exist | ||||
|             FileUtils.deleteDirectory(this.uncompressedPath.toFile()); | ||||
|  | ||||
|         } catch (IOException e) { | ||||
|             throw new DataException("Unable to create uncompressed directory"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void deleteExistingFiles() { | ||||
|         final Path uncompressedPath = this.uncompressedPath; | ||||
|         if (FilesystemUtils.pathInsideDataOrTempPath(uncompressedPath)) { | ||||
|             if (Files.exists(uncompressedPath)) { | ||||
|                 LOGGER.trace("Attempting to delete path {}", this.uncompressedPath); | ||||
|                 try { | ||||
|                     Files.walkFileTree(uncompressedPath, new SimpleFileVisitor<>() { | ||||
|  | ||||
|                         @Override | ||||
|                         public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { | ||||
|                             Files.delete(file); | ||||
|                             return FileVisitResult.CONTINUE; | ||||
|                         } | ||||
|  | ||||
|                         @Override | ||||
|                         public FileVisitResult postVisitDirectory(Path dir, IOException e) throws IOException { | ||||
|                             // Don't delete the parent directory, as we want to leave an empty folder | ||||
|                             if (dir.compareTo(uncompressedPath) == 0) { | ||||
|                                 return FileVisitResult.CONTINUE; | ||||
|                             } | ||||
|  | ||||
|                             if (e == null) { | ||||
|                                 Files.delete(dir); | ||||
|                                 return FileVisitResult.CONTINUE; | ||||
|                             } else { | ||||
|                                 throw e; | ||||
|                             } | ||||
|                         } | ||||
|  | ||||
|                     }); | ||||
|                 } catch (IOException e) { | ||||
|                     LOGGER.debug("Unable to delete file or directory: {}", e.getMessage()); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void fetch() throws DataException, IOException, MissingDataException { | ||||
|         switch (resourceIdType) { | ||||
|  | ||||
|             case FILE_HASH: | ||||
|                 this.fetchFromFileHash(); | ||||
|                 break; | ||||
|  | ||||
|             case NAME: | ||||
|                 this.fetchFromName(); | ||||
|                 break; | ||||
|  | ||||
|             case SIGNATURE: | ||||
|                 this.fetchFromSignature(); | ||||
|                 break; | ||||
|  | ||||
|             case TRANSACTION_DATA: | ||||
|                 this.fetchFromTransactionData(this.transactionData); | ||||
|                 break; | ||||
|  | ||||
|             default: | ||||
|                 throw new DataException(String.format("Unknown resource ID type specified: %s", resourceIdType.toString())); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void fetchFromFileHash() throws DataException { | ||||
|         // Load data file directly from the hash (without a signature) | ||||
|         ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash58(resourceId, null); | ||||
|         // Set filePath to the location of the ArbitraryDataFile | ||||
|         this.filePath = arbitraryDataFile.getFilePath(); | ||||
|     } | ||||
|  | ||||
|     private void fetchFromName() throws DataException, IOException, MissingDataException { | ||||
|         try { | ||||
|  | ||||
|             // Build the existing state using past transactions | ||||
|             ArbitraryDataBuilder builder = new ArbitraryDataBuilder(this.resourceId, this.service, this.identifier); | ||||
|             builder.build(); | ||||
|             Path builtPath = builder.getFinalPath(); | ||||
|             if (builtPath == null) { | ||||
|                 throw new DataException("Unable to build path"); | ||||
|             } | ||||
|  | ||||
|             // Update stats | ||||
|             this.layerCount = builder.getLayerCount(); | ||||
|             this.latestSignature = builder.getLatestSignature(); | ||||
|  | ||||
|             // Set filePath to the builtPath | ||||
|             this.filePath = builtPath; | ||||
|  | ||||
|         } catch (InvalidObjectException e) { | ||||
|             // Hash validation failed. Invalidate the cache for this name, so it can be rebuilt | ||||
|             LOGGER.info("Deleting {}", this.workingPath.toString()); | ||||
|             FilesystemUtils.safeDeleteDirectory(this.workingPath, false); | ||||
|             throw(e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void fetchFromSignature() throws DataException, IOException, MissingDataException { | ||||
|  | ||||
|         // Load the full transaction data from the database so we can access the file hashes | ||||
|         ArbitraryTransactionData transactionData; | ||||
|         try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|             transactionData = (ArbitraryTransactionData) repository.getTransactionRepository().fromSignature(Base58.decode(resourceId)); | ||||
|         } | ||||
|         if (transactionData == null) { | ||||
|             throw new DataException(String.format("Transaction data not found for signature %s", this.resourceId)); | ||||
|         } | ||||
|  | ||||
|         this.fetchFromTransactionData(transactionData); | ||||
|     } | ||||
|  | ||||
|     private void fetchFromTransactionData(ArbitraryTransactionData transactionData) throws DataException, IOException, MissingDataException { | ||||
|         if (transactionData == null) { | ||||
|             throw new DataException(String.format("Transaction data not found for signature %s", this.resourceId)); | ||||
|         } | ||||
|  | ||||
|         // Load hashes | ||||
|         byte[] digest = transactionData.getData(); | ||||
|         byte[] metadataHash = transactionData.getMetadataHash(); | ||||
|         byte[] signature = transactionData.getSignature(); | ||||
|  | ||||
|         // Load secret | ||||
|         byte[] secret = transactionData.getSecret(); | ||||
|         if (secret != null) { | ||||
|             this.secret58 = Base58.encode(secret); | ||||
|         } | ||||
|  | ||||
|         // Load data file(s) | ||||
|         ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(digest, signature); | ||||
|         ArbitraryTransactionUtils.checkAndRelocateMiscFiles(transactionData); | ||||
|         arbitraryDataFile.setMetadataHash(metadataHash); | ||||
|  | ||||
|         if (!arbitraryDataFile.allFilesExist()) { | ||||
|             if (ArbitraryDataStorageManager.getInstance().isNameBlocked(transactionData.getName())) { | ||||
|                 throw new DataException( | ||||
|                         String.format("Unable to request missing data for file %s because the name is blocked", arbitraryDataFile)); | ||||
|             } | ||||
|             else { | ||||
|                 // Ask the arbitrary data manager to fetch data for this transaction | ||||
|                 String message; | ||||
|                 if (this.canRequestMissingFiles) { | ||||
|                     boolean requested = ArbitraryDataManager.getInstance().fetchData(transactionData); | ||||
|  | ||||
|                     if (requested) { | ||||
|                         message = String.format("Requested missing data for file %s", arbitraryDataFile); | ||||
|                     } else { | ||||
|                         message = String.format("Unable to reissue request for missing file %s for signature %s due to rate limit. Please try again later.", arbitraryDataFile, Base58.encode(transactionData.getSignature())); | ||||
|                     } | ||||
|                 } | ||||
|                 else { | ||||
|                     message = String.format("Missing data for file %s", arbitraryDataFile); | ||||
|                 } | ||||
|  | ||||
|                 // Throw a missing data exception, which allows subsequent layers to fetch data | ||||
|                 LOGGER.trace(message); | ||||
|                 throw new MissingDataException(message); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (arbitraryDataFile.allChunksExist() && !arbitraryDataFile.exists()) { | ||||
|             // We have all the chunks but not the complete file, so join them | ||||
|             arbitraryDataFile.join(); | ||||
|         } | ||||
|  | ||||
|         // If the complete file still doesn't exist then something went wrong | ||||
|         if (!arbitraryDataFile.exists()) { | ||||
|             throw new IOException(String.format("File doesn't exist: %s", arbitraryDataFile)); | ||||
|         } | ||||
|         // Ensure the complete hash matches the joined chunks | ||||
|         if (!Arrays.equals(arbitraryDataFile.digest(), digest)) { | ||||
|             // Delete the invalid file | ||||
|             arbitraryDataFile.delete(); | ||||
|             throw new DataException("Unable to validate complete file hash"); | ||||
|         } | ||||
|         // Ensure the file's size matches the size reported by the transaction (throws a DataException if not) | ||||
|         arbitraryDataFile.validateFileSize(transactionData.getSize()); | ||||
|  | ||||
|         // Set filePath to the location of the ArbitraryDataFile | ||||
|         this.filePath = arbitraryDataFile.getFilePath(); | ||||
|     } | ||||
|  | ||||
|     private void decrypt() throws DataException { | ||||
|         try { | ||||
|             // First try with explicit parameters (CBC mode with PKCS5 padding) | ||||
|             this.decryptUsingAlgo("AES/CBC/PKCS5Padding"); | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             // Something went wrong, so fall back to default AES params (necessary for legacy resource support) | ||||
|             this.decryptUsingAlgo("AES"); | ||||
|  | ||||
|             // TODO: delete files and block this resource if privateDataEnabled is false and the second attempt fails too | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void decryptUsingAlgo(String algorithm) throws DataException { | ||||
|         // Decrypt if we have the secret key. | ||||
|         byte[] secret = this.secret58 != null ? Base58.decode(this.secret58) : null; | ||||
|         if (secret != null && secret.length == Transformer.AES256_LENGTH) { | ||||
|             try { | ||||
|                 Path unencryptedPath = Paths.get(this.workingPath.toString(), "zipped.zip"); | ||||
|                 SecretKey aesKey = new SecretKeySpec(secret, 0, secret.length, algorithm); | ||||
|                 AES.decryptFile(algorithm, aesKey, this.filePath.toString(), unencryptedPath.toString()); | ||||
|  | ||||
|                 // Replace filePath pointer with the encrypted file path | ||||
|                 // Don't delete the original ArbitraryDataFile, as this is handled in the cleanup phase | ||||
|                 this.filePath = unencryptedPath; | ||||
|  | ||||
|             } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | NoSuchPaddingException | ||||
|                     | BadPaddingException | IllegalBlockSizeException | IOException | InvalidKeyException e) { | ||||
|                 throw new DataException(String.format("Unable to decrypt file at path %s: %s", this.filePath, e.getMessage())); | ||||
|             } | ||||
|         } else { | ||||
|             // Assume it is unencrypted. This will be the case when we have built a custom path by combining | ||||
|             // multiple decrypted archives into a single state. | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void uncompress() throws IOException, DataException { | ||||
|         if (this.filePath == null || !Files.exists(this.filePath)) { | ||||
|             throw new DataException("Can't uncompress non-existent file path"); | ||||
|         } | ||||
|         File file = new File(this.filePath.toString()); | ||||
|         if (file.isDirectory()) { | ||||
|             // Already a directory - nothing to uncompress | ||||
|             // We still need to copy the directory to its final destination if it's not already there | ||||
|             this.moveFilePathToFinalDestination(); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             // Default to ZIP compression - this is needed for previews | ||||
|             Compression compression = transactionData != null ? transactionData.getCompression() : Compression.ZIP; | ||||
|  | ||||
|             // Handle each type of compression | ||||
|             if (compression == Compression.ZIP) { | ||||
|                 ZipUtils.unzip(this.filePath.toString(), this.uncompressedPath.getParent().toString()); | ||||
|             } | ||||
|             else if (compression == Compression.NONE) { | ||||
|                 Files.createDirectories(this.uncompressedPath); | ||||
|                 Path finalPath = Paths.get(this.uncompressedPath.toString(), "data"); | ||||
|                 this.filePath.toFile().renameTo(finalPath.toFile()); | ||||
|             } | ||||
|             else { | ||||
|                 throw new DataException(String.format("Unrecognized compression type: %s", transactionData.getCompression())); | ||||
|             } | ||||
|         } catch (IOException e) { | ||||
|             throw new DataException(String.format("Unable to unzip file: %s", e.getMessage())); | ||||
|         } | ||||
|  | ||||
|         // Replace filePath pointer with the uncompressed file path | ||||
|         if (FilesystemUtils.pathInsideDataOrTempPath(this.filePath)) { | ||||
|             if (Files.exists(this.filePath)) { | ||||
|                 Files.delete(this.filePath); | ||||
|             } | ||||
|         } | ||||
|         this.filePath = this.uncompressedPath; | ||||
|     } | ||||
|  | ||||
|     private void validate() throws IOException, DataException { | ||||
|         if (this.service.isValidationRequired()) { | ||||
|             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())); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     private void moveFilePathToFinalDestination() throws IOException, DataException { | ||||
|         if (this.filePath.compareTo(this.uncompressedPath) != 0) { | ||||
|             File source = new File(this.filePath.toString()); | ||||
|             File dest = new File(this.uncompressedPath.toString()); | ||||
|             if (!source.exists()) { | ||||
|                 throw new DataException("Source directory doesn't exist"); | ||||
|             } | ||||
|             // Ensure destination directory doesn't exist | ||||
|             FileUtils.deleteDirectory(dest); | ||||
|             // Move files to destination | ||||
|             FilesystemUtils.copyAndReplaceDirectory(source.toString(), dest.toString()); | ||||
|  | ||||
|             try { | ||||
|                 // Delete existing | ||||
|                 if (FilesystemUtils.pathInsideDataOrTempPath(this.filePath)) { | ||||
|                     File directory = new File(this.filePath.toString()); | ||||
|                     FileUtils.deleteDirectory(directory); | ||||
|                 } | ||||
|  | ||||
|                 // ... and its parent directory if empty | ||||
|                 Path parentDirectory = this.filePath.getParent(); | ||||
|                 if (FilesystemUtils.pathInsideDataOrTempPath(parentDirectory)) { | ||||
|                     Files.deleteIfExists(parentDirectory); | ||||
|                 } | ||||
|  | ||||
|             } catch (DirectoryNotEmptyException e) { | ||||
|                     // No need to log anything | ||||
|             } catch (IOException e) { | ||||
|                 // This will eventually be cleaned up by a maintenance process, so log the error and continue | ||||
|                 LOGGER.debug("Unable to cleanup directories: {}", e.getMessage()); | ||||
|             } | ||||
|  | ||||
|             // Finally, update filePath to point to uncompressedPath | ||||
|             this.filePath = this.uncompressedPath; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public void setTransactionData(ArbitraryTransactionData transactionData) { | ||||
|         this.transactionData = transactionData; | ||||
|     } | ||||
|  | ||||
|     public void setSecret58(String secret58) { | ||||
|         this.secret58 = secret58; | ||||
|     } | ||||
|  | ||||
|     public Path getFilePath() { | ||||
|         return this.filePath; | ||||
|     } | ||||
|  | ||||
|     public int getLayerCount() { | ||||
|         return this.layerCount; | ||||
|     } | ||||
|  | ||||
|     public byte[] getLatestSignature() { | ||||
|         return this.latestSignature; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Use the below setter to ensure that we only read existing | ||||
|      * data without requesting any missing files, | ||||
|      * | ||||
|      * @param canRequestMissingFiles - whether or not fetching missing files is allowed | ||||
|      */ | ||||
|     public void setCanRequestMissingFiles(boolean canRequestMissingFiles) { | ||||
|         this.canRequestMissingFiles = canRequestMissingFiles; | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										213
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										213
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,213 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| import com.google.common.io.Resources; | ||||
| import org.apache.commons.io.FileUtils; | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.api.HTMLParser; | ||||
| import org.qortal.arbitrary.ArbitraryDataFile.*; | ||||
| import org.qortal.arbitrary.exception.MissingDataException; | ||||
| import org.qortal.arbitrary.misc.Service; | ||||
| import org.qortal.controller.Controller; | ||||
| import org.qortal.settings.Settings; | ||||
|  | ||||
| import javax.servlet.ServletContext; | ||||
| import javax.servlet.http.HttpServletRequest; | ||||
| import javax.servlet.http.HttpServletResponse; | ||||
| import java.io.File; | ||||
| import java.io.FileInputStream; | ||||
| import java.io.FileNotFoundException; | ||||
| import java.io.IOException; | ||||
| import java.net.URL; | ||||
| import java.nio.charset.StandardCharsets; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.NoSuchFileException; | ||||
| import java.nio.file.Path; | ||||
| import java.nio.file.Paths; | ||||
| import java.util.ArrayList; | ||||
| import java.util.List; | ||||
|  | ||||
| public class ArbitraryDataRenderer { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataRenderer.class); | ||||
|  | ||||
|     private final String resourceId; | ||||
|     private final ResourceIdType resourceIdType; | ||||
|     private final Service service; | ||||
|     private String inPath; | ||||
|     private final String secret58; | ||||
|     private final String prefix; | ||||
|     private final boolean usePrefix; | ||||
|     private final boolean async; | ||||
|     private final HttpServletRequest request; | ||||
|     private final HttpServletResponse response; | ||||
|     private final ServletContext context; | ||||
|  | ||||
|     public ArbitraryDataRenderer(String resourceId, ResourceIdType resourceIdType, Service service, String inPath, | ||||
|                                  String secret58, String prefix, boolean usePrefix, boolean async, | ||||
|                                  HttpServletRequest request, HttpServletResponse response, ServletContext context) { | ||||
|  | ||||
|         this.resourceId = resourceId; | ||||
|         this.resourceIdType = resourceIdType; | ||||
|         this.service = service; | ||||
|         this.inPath = inPath; | ||||
|         this.secret58 = secret58; | ||||
|         this.prefix = prefix; | ||||
|         this.usePrefix = usePrefix; | ||||
|         this.async = async; | ||||
|         this.request = request; | ||||
|         this.response = response; | ||||
|         this.context = context; | ||||
|     } | ||||
|  | ||||
|     public HttpServletResponse render() { | ||||
|         if (!inPath.startsWith(File.separator)) { | ||||
|             inPath = File.separator + inPath; | ||||
|         } | ||||
|  | ||||
|         // Don't render data if QDN is disabled | ||||
|         if (!Settings.getInstance().isQdnEnabled()) { | ||||
|             return ArbitraryDataRenderer.getResponse(response, 500, "QDN is disabled in settings"); | ||||
|         } | ||||
|  | ||||
|         ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(resourceId, resourceIdType, service, null); | ||||
|         arbitraryDataReader.setSecret58(secret58); // Optional, used for loading encrypted file hashes only | ||||
|         try { | ||||
|             if (!arbitraryDataReader.isCachedDataAvailable()) { | ||||
|                 // If async is requested, show a loading screen whilst build is in progress | ||||
|                 if (async) { | ||||
|                     arbitraryDataReader.loadAsynchronously(false); | ||||
|                     return this.getLoadingResponse(service, resourceId); | ||||
|                 } | ||||
|  | ||||
|                 // Otherwise, loop until we have data | ||||
|                 int attempts = 0; | ||||
|                 while (!Controller.isStopping()) { | ||||
|                     attempts++; | ||||
|                     if (!arbitraryDataReader.isBuilding()) { | ||||
|                         try { | ||||
|                             arbitraryDataReader.loadSynchronously(false); | ||||
|                             break; | ||||
|                         } catch (MissingDataException e) { | ||||
|                             if (attempts > 5) { | ||||
|                                 // Give up after 5 attempts | ||||
|                                 return ArbitraryDataRenderer.getResponse(response, 404, "Data unavailable. Please try again later."); | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                     Thread.sleep(3000L); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|         } catch (Exception e) { | ||||
|             LOGGER.info(String.format("Unable to load %s %s: %s", service, resourceId, e.getMessage())); | ||||
|             return ArbitraryDataRenderer.getResponse(response, 500, "Error 500: Internal Server Error"); | ||||
|         } | ||||
|  | ||||
|         java.nio.file.Path path = arbitraryDataReader.getFilePath(); | ||||
|         if (path == null) { | ||||
|             return ArbitraryDataRenderer.getResponse(response, 404, "Error 404: File Not Found"); | ||||
|         } | ||||
|         String unzippedPath = path.toString(); | ||||
|  | ||||
|         try { | ||||
|             String filename = this.getFilename(unzippedPath, inPath); | ||||
|             String filePath = Paths.get(unzippedPath, filename).toString(); | ||||
|  | ||||
|             if (HTMLParser.isHtmlFile(filename)) { | ||||
|                 // HTML file - needs to be parsed | ||||
|                 byte[] data = Files.readAllBytes(Paths.get(filePath)); // TODO: limit file size that can be read into memory | ||||
|                 HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, usePrefix, data); | ||||
|                 htmlParser.addAdditionalHeaderTags(); | ||||
|                 response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline'; media-src 'self' blob:"); | ||||
|                 response.setContentType(context.getMimeType(filename)); | ||||
|                 response.setContentLength(htmlParser.getData().length); | ||||
|                 response.getOutputStream().write(htmlParser.getData()); | ||||
|             } | ||||
|             else { | ||||
|                 // Regular file - can be streamed directly | ||||
|                 File file = new File(filePath); | ||||
|                 FileInputStream inputStream = new FileInputStream(file); | ||||
|                 response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline'; media-src 'self' blob:"); | ||||
|                 response.setContentType(context.getMimeType(filename)); | ||||
|                 int bytesRead, length = 0; | ||||
|                 byte[] buffer = new byte[10240]; | ||||
|                 while ((bytesRead = inputStream.read(buffer)) != -1) { | ||||
|                     response.getOutputStream().write(buffer, 0, bytesRead); | ||||
|                     length += bytesRead; | ||||
|                 } | ||||
|                 response.setContentLength(length); | ||||
|                 inputStream.close(); | ||||
|             } | ||||
|             return response; | ||||
|         } catch (FileNotFoundException | NoSuchFileException e) { | ||||
|             LOGGER.info("Unable to serve file: {}", e.getMessage()); | ||||
|             if (inPath.equals("/")) { | ||||
|                 // Delete the unzipped folder if no index file was found | ||||
|                 try { | ||||
|                     FileUtils.deleteDirectory(new File(unzippedPath)); | ||||
|                 } catch (IOException ioException) { | ||||
|                     LOGGER.debug("Unable to delete directory: {}", unzippedPath, e); | ||||
|                 } | ||||
|             } | ||||
|         } catch (IOException e) { | ||||
|             LOGGER.info("Unable to serve file at path {}: {}", inPath, e.getMessage()); | ||||
|         } | ||||
|  | ||||
|         return ArbitraryDataRenderer.getResponse(response, 404, "Error 404: File Not Found"); | ||||
|     } | ||||
|  | ||||
|     private String getFilename(String directory, String userPath) { | ||||
|         if (userPath == null || userPath.endsWith("/") || userPath.equals("")) { | ||||
|             // Locate index file | ||||
|             List<String> indexFiles = ArbitraryDataRenderer.indexFiles(); | ||||
|             for (String indexFile : indexFiles) { | ||||
|                 Path path = Paths.get(directory, indexFile); | ||||
|                 if (Files.exists(path)) { | ||||
|                     return userPath + indexFile; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         return userPath; | ||||
|     } | ||||
|  | ||||
|     private HttpServletResponse getLoadingResponse(Service service, String name) { | ||||
|         String responseString = ""; | ||||
|         URL url = Resources.getResource("loading/index.html"); | ||||
|         try { | ||||
|             responseString = Resources.toString(url, StandardCharsets.UTF_8); | ||||
|  | ||||
|             // Replace vars | ||||
|             responseString = responseString.replace("%%SERVICE%%", service.toString()); | ||||
|             responseString = responseString.replace("%%NAME%%", name); | ||||
|  | ||||
|         } catch (IOException e) { | ||||
|             LOGGER.info("Unable to show loading screen: {}", e.getMessage()); | ||||
|         } | ||||
|         return ArbitraryDataRenderer.getResponse(response, 503, responseString); | ||||
|     } | ||||
|  | ||||
|     public static HttpServletResponse getResponse(HttpServletResponse response, int responseCode, String responseString) { | ||||
|         try { | ||||
|             byte[] responseData = responseString.getBytes(); | ||||
|             response.setStatus(responseCode); | ||||
|             response.setContentLength(responseData.length); | ||||
|             response.getOutputStream().write(responseData); | ||||
|         } catch (IOException e) { | ||||
|             LOGGER.info("Error writing {} response", responseCode); | ||||
|         } | ||||
|         return response; | ||||
|     } | ||||
|  | ||||
|     public static List<String> indexFiles() { | ||||
|         List<String> indexFiles = new ArrayList<>(); | ||||
|         indexFiles.add("index.html"); | ||||
|         indexFiles.add("index.htm"); | ||||
|         indexFiles.add("default.html"); | ||||
|         indexFiles.add("default.htm"); | ||||
|         indexFiles.add("home.html"); | ||||
|         indexFiles.add("home.htm"); | ||||
|         return indexFiles; | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										352
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										352
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,352 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType; | ||||
| import org.qortal.arbitrary.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.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.NTP; | ||||
|  | ||||
| import java.io.IOException; | ||||
| import java.nio.file.Path; | ||||
| import java.nio.file.Paths; | ||||
| import java.util.ArrayList; | ||||
| import java.util.List; | ||||
|  | ||||
| import static org.qortal.data.arbitrary.ArbitraryResourceStatus.Status; | ||||
|  | ||||
| public class ArbitraryDataResource { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataResource.class); | ||||
|  | ||||
|     protected final String resourceId; | ||||
|     protected final ResourceIdType resourceIdType; | ||||
|     protected final Service service; | ||||
|     protected final String identifier; | ||||
|  | ||||
|     private List<ArbitraryTransactionData> transactions; | ||||
|     private ArbitraryTransactionData latestPutTransaction; | ||||
|     private int layerCount; | ||||
|     private Integer localChunkCount = null; | ||||
|     private Integer totalChunkCount = null; | ||||
|  | ||||
|     public ArbitraryDataResource(String resourceId, ResourceIdType resourceIdType, Service service, String identifier) { | ||||
|         this.resourceId = resourceId.toLowerCase(); | ||||
|         this.resourceIdType = resourceIdType; | ||||
|         this.service = service; | ||||
|  | ||||
|         // If identifier is a blank string, or reserved keyword "default", treat it as null | ||||
|         if (identifier == null || identifier.equals("") || identifier.equals("default")) { | ||||
|             identifier = null; | ||||
|         } | ||||
|         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(); | ||||
|         } | ||||
|  | ||||
|         if (resourceIdType != ResourceIdType.NAME) { | ||||
|             // We only support statuses for resources with a name | ||||
|             return new ArbitraryResourceStatus(Status.UNSUPPORTED, this.localChunkCount, this.totalChunkCount); | ||||
|         } | ||||
|  | ||||
|         // Check if the name is blocked | ||||
|         if (ResourceListManager.getInstance() | ||||
|                 .listContains("blockedNames", this.resourceId, false)) { | ||||
|             return new ArbitraryResourceStatus(Status.BLOCKED, this.localChunkCount, this.totalChunkCount); | ||||
|         } | ||||
|  | ||||
|         // Check if a build has failed | ||||
|         ArbitraryDataBuildQueueItem queueItem = | ||||
|                 new ArbitraryDataBuildQueueItem(resourceId, resourceIdType, service, identifier); | ||||
|         if (ArbitraryDataBuildManager.getInstance().isInFailedBuildsList(queueItem)) { | ||||
|             return new ArbitraryResourceStatus(Status.BUILD_FAILED, this.localChunkCount, this.totalChunkCount); | ||||
|         } | ||||
|  | ||||
|         // 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); | ||||
|         } | ||||
|  | ||||
|         // Check if we have all data locally for this resource | ||||
|         if (!this.allFilesDownloaded()) { | ||||
|             if (this.isDownloading()) { | ||||
|                 return new ArbitraryResourceStatus(Status.DOWNLOADING, this.localChunkCount, this.totalChunkCount); | ||||
|             } | ||||
|             else if (this.isDataPotentiallyAvailable()) { | ||||
|                 return new ArbitraryResourceStatus(Status.PUBLISHED, this.localChunkCount, this.totalChunkCount); | ||||
|             } | ||||
|             return new ArbitraryResourceStatus(Status.MISSING_DATA, this.localChunkCount, this.totalChunkCount); | ||||
|         } | ||||
|  | ||||
|         // Check if there's a build in progress | ||||
|         if (ArbitraryDataBuildManager.getInstance().isInBuildQueue(queueItem)) { | ||||
|             return new ArbitraryResourceStatus(Status.BUILDING, this.localChunkCount, this.totalChunkCount); | ||||
|         } | ||||
|  | ||||
|         // We have all data locally | ||||
|         return new ArbitraryResourceStatus(Status.DOWNLOADED, this.localChunkCount, this.totalChunkCount); | ||||
|     } | ||||
|  | ||||
|     public boolean delete() { | ||||
|         try { | ||||
|             this.fetchTransactions(); | ||||
|  | ||||
|             List<ArbitraryTransactionData> transactionDataList = new ArrayList<>(this.transactions); | ||||
|  | ||||
|             for (ArbitraryTransactionData transactionData : transactionDataList) { | ||||
|                 byte[] hash = transactionData.getData(); | ||||
|                 byte[] metadataHash = transactionData.getMetadataHash(); | ||||
|                 byte[] signature = transactionData.getSignature(); | ||||
|                 ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(hash, signature); | ||||
|                 arbitraryDataFile.setMetadataHash(metadataHash); | ||||
|  | ||||
|                 // Delete any chunks or complete files from each transaction | ||||
|                 arbitraryDataFile.deleteAll(); | ||||
|             } | ||||
|  | ||||
|             // Also delete cached data for the entire resource | ||||
|             this.deleteCache(); | ||||
|  | ||||
|             // Invalidate the hosted transactions cache as we have removed an item | ||||
|             ArbitraryDataStorageManager.getInstance().invalidateHostedTransactionsCache(); | ||||
|  | ||||
|             return true; | ||||
|  | ||||
|         } catch (DataException | IOException e) { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void deleteCache() throws IOException { | ||||
|         // Don't delete anything if there's a build in progress | ||||
|         ArbitraryDataBuildQueueItem queueItem = | ||||
|                 new ArbitraryDataBuildQueueItem(resourceId, resourceIdType, service, identifier); | ||||
|         if (ArbitraryDataBuildManager.getInstance().isInBuildQueue(queueItem)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         String baseDir = Settings.getInstance().getTempDataPath(); | ||||
|         String identifier = this.identifier != null ?  this.identifier : "default"; | ||||
|         Path cachePath = Paths.get(baseDir, "reader", this.resourceIdType.toString(), this.resourceId, this.service.toString(), identifier); | ||||
|         if (cachePath.toFile().exists()) { | ||||
|             boolean success = FilesystemUtils.safeDeleteDirectory(cachePath, true); | ||||
|             if (success) { | ||||
|                 LOGGER.info("Cleared cache for resource {}", this.toString()); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private boolean allFilesDownloaded() { | ||||
|         // Use chunk counts to speed things up if we can | ||||
|         if (this.localChunkCount != null && this.totalChunkCount != null && | ||||
|                 this.localChunkCount >= this.totalChunkCount) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             this.fetchTransactions(); | ||||
|  | ||||
|             List<ArbitraryTransactionData> transactionDataList = new ArrayList<>(this.transactions); | ||||
|  | ||||
|             for (ArbitraryTransactionData transactionData : transactionDataList) { | ||||
|                 if (!ArbitraryTransactionUtils.completeFileExists(transactionData) || | ||||
|                     !ArbitraryTransactionUtils.allChunksExist(transactionData)) { | ||||
|                     return false; | ||||
|                 } | ||||
|             } | ||||
|             return true; | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void calculateChunkCounts() { | ||||
|         try { | ||||
|             this.fetchTransactions(); | ||||
|  | ||||
|             List<ArbitraryTransactionData> transactionDataList = new ArrayList<>(this.transactions); | ||||
|             int localChunkCount = 0; | ||||
|             int totalChunkCount = 0; | ||||
|  | ||||
|             for (ArbitraryTransactionData transactionData : transactionDataList) { | ||||
|                 localChunkCount += ArbitraryTransactionUtils.ourChunkCount(transactionData); | ||||
|                 totalChunkCount += ArbitraryTransactionUtils.totalChunkCount(transactionData); | ||||
|             } | ||||
|  | ||||
|             this.localChunkCount = localChunkCount; | ||||
|             this.totalChunkCount = totalChunkCount; | ||||
|  | ||||
|         } catch (DataException e) {} | ||||
|     } | ||||
|  | ||||
|     private boolean isRateLimited() { | ||||
|         try { | ||||
|             this.fetchTransactions(); | ||||
|  | ||||
|             List<ArbitraryTransactionData> transactionDataList = new ArrayList<>(this.transactions); | ||||
|  | ||||
|             for (ArbitraryTransactionData transactionData : transactionDataList) { | ||||
|                 if (ArbitraryDataManager.getInstance().isSignatureRateLimited(transactionData.getSignature())) { | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|             return true; | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Best guess as to whether data might be available | ||||
|      * 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() { | ||||
|         try { | ||||
|             this.fetchTransactions(); | ||||
|             Long now = NTP.getTime(); | ||||
|             if (now == null) { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             List<ArbitraryTransactionData> transactionDataList = new ArrayList<>(this.transactions); | ||||
|  | ||||
|             for (ArbitraryTransactionData transactionData : transactionDataList) { | ||||
|                 long lastRequestTime = ArbitraryDataManager.getInstance().lastRequestForSignature(transactionData.getSignature()); | ||||
|                 // If we haven't requested yet, or requested in the last 30 seconds, there's still a | ||||
|                 // chance that data is on its way but hasn't arrived yet | ||||
|                 if (lastRequestTime == 0 || now - lastRequestTime < 30 * 1000L) { | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|             return false; | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     /** | ||||
|      * Best guess as to whether we are currently downloading a resource | ||||
|      * 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() { | ||||
|         try { | ||||
|             this.fetchTransactions(); | ||||
|             Long now = NTP.getTime(); | ||||
|             if (now == null) { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             List<ArbitraryTransactionData> transactionDataList = new ArrayList<>(this.transactions); | ||||
|  | ||||
|             for (ArbitraryTransactionData transactionData : transactionDataList) { | ||||
|                 long lastRequestTime = ArbitraryDataManager.getInstance().lastRequestForSignature(transactionData.getSignature()); | ||||
|                 // If were have requested data in the last 30 seconds, treat it as "downloading" | ||||
|                 if (lastRequestTime > 0 && now - lastRequestTime < 30 * 1000L) { | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // FUTURE: we may want to check for file hashes (including the metadata file hash) in | ||||
|             // ArbitraryDataManager.arbitraryDataFileRequests and return true if one is found. | ||||
|  | ||||
|             return false; | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|  | ||||
|     private void fetchTransactions() throws DataException { | ||||
|         if (this.transactions != null && !this.transactions.isEmpty()) { | ||||
|             // Already fetched | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|  | ||||
|             // Get the most recent PUT | ||||
|             ArbitraryTransactionData latestPut = repository.getArbitraryRepository() | ||||
|                     .getLatestTransaction(this.resourceId, this.service, ArbitraryTransactionData.Method.PUT, this.identifier); | ||||
|             if (latestPut == null) { | ||||
|                 String message = String.format("Couldn't find PUT transaction for name %s, service %s and identifier %s", | ||||
|                         this.resourceId, this.service, this.identifierString()); | ||||
|                 throw new DataException(message); | ||||
|             } | ||||
|             this.latestPutTransaction = latestPut; | ||||
|  | ||||
|             // Load all transactions since the latest PUT | ||||
|             List<ArbitraryTransactionData> transactionDataList = repository.getArbitraryRepository() | ||||
|                     .getArbitraryTransactions(this.resourceId, this.service, this.identifier, latestPut.getTimestamp()); | ||||
|  | ||||
|             this.transactions = transactionDataList; | ||||
|             this.layerCount = transactionDataList.size(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private String resourceIdString() { | ||||
|         return resourceId != null ? resourceId : ""; | ||||
|     } | ||||
|  | ||||
|     private String resourceIdTypeString() { | ||||
|         return resourceIdType != null ? resourceIdType.toString() : ""; | ||||
|     } | ||||
|  | ||||
|     private String serviceString() { | ||||
|         return service != null ? service.toString() : ""; | ||||
|     } | ||||
|  | ||||
|     private String identifierString() { | ||||
|         return identifier != null ? identifier : ""; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public String toString() { | ||||
|         return String.format("%s %s %s", this.serviceString(), this.resourceIdString(), this.identifierString()); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     /** | ||||
|      * @return unique key used to identify this resource | ||||
|      */ | ||||
|     public String getUniqueKey() { | ||||
|         return String.format("%s-%s-%s", this.service, this.resourceId, this.identifier).toLowerCase(); | ||||
|     } | ||||
|  | ||||
|     public String getResourceId() { | ||||
|         return this.resourceId; | ||||
|     } | ||||
|  | ||||
|     public Service getService() { | ||||
|         return this.service; | ||||
|     } | ||||
|  | ||||
|     public String getIdentifier() { | ||||
|         return this.identifier; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,285 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.arbitrary.exception.MissingDataException; | ||||
| import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType; | ||||
| import org.qortal.arbitrary.ArbitraryDataDiff.*; | ||||
| import org.qortal.arbitrary.metadata.ArbitraryDataMetadataPatch; | ||||
| import org.qortal.arbitrary.misc.Service; | ||||
| import org.qortal.block.BlockChain; | ||||
| import org.qortal.crypto.Crypto; | ||||
| import org.qortal.data.PaymentData; | ||||
| import org.qortal.data.transaction.ArbitraryTransactionData; | ||||
| import org.qortal.data.transaction.ArbitraryTransactionData.*; | ||||
| import org.qortal.data.transaction.BaseTransactionData; | ||||
| import org.qortal.group.Group; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.transaction.ArbitraryTransaction; | ||||
| import org.qortal.transaction.Transaction; | ||||
| import org.qortal.transform.Transformer; | ||||
| import org.qortal.utils.Base58; | ||||
| import org.qortal.utils.FilesystemUtils; | ||||
| import org.qortal.utils.NTP; | ||||
|  | ||||
| import java.io.IOException; | ||||
| import java.nio.file.Path; | ||||
| import java.util.ArrayList; | ||||
| import java.util.List; | ||||
| import java.util.Random; | ||||
|  | ||||
| public class ArbitraryDataTransactionBuilder { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataTransactionBuilder.class); | ||||
|  | ||||
|     // Min transaction version required | ||||
|     private static final int MIN_TRANSACTION_VERSION = 5; | ||||
|  | ||||
|     // Maximum number of PATCH layers allowed | ||||
|     private static final int MAX_LAYERS = 10; | ||||
|     // Maximum size difference (out of 1) allowed for PATCH transactions | ||||
|     private static final double MAX_SIZE_DIFF = 0.2f; | ||||
|     // Maximum proportion of files modified relative to total | ||||
|     private static final double MAX_FILE_DIFF = 0.5f; | ||||
|  | ||||
|     private final String publicKey58; | ||||
|     private final Path path; | ||||
|     private final String name; | ||||
|     private Method method; | ||||
|     private final Service service; | ||||
|     private final String identifier; | ||||
|     private final Repository repository; | ||||
|  | ||||
|     private int chunkSize = ArbitraryDataFile.CHUNK_SIZE; | ||||
|  | ||||
|     private ArbitraryTransactionData arbitraryTransactionData; | ||||
|     private ArbitraryDataFile arbitraryDataFile; | ||||
|  | ||||
|     public ArbitraryDataTransactionBuilder(Repository repository, String publicKey58, Path path, String name, | ||||
|                                            Method method, Service service, String identifier) { | ||||
|         this.repository = repository; | ||||
|         this.publicKey58 = publicKey58; | ||||
|         this.path = path; | ||||
|         this.name = name; | ||||
|         this.method = method; | ||||
|         this.service = service; | ||||
|  | ||||
|         // If identifier is a blank string, or reserved keyword "default", treat it as null | ||||
|         if (identifier == null || identifier.equals("") || identifier.equals("default")) { | ||||
|             identifier = null; | ||||
|         } | ||||
|         this.identifier = identifier; | ||||
|     } | ||||
|  | ||||
|     public void build() throws DataException { | ||||
|         try { | ||||
|             this.preExecute(); | ||||
|             this.checkMethod(); | ||||
|             this.createTransaction(); | ||||
|         } | ||||
|         finally { | ||||
|             this.postExecute(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void preExecute() { | ||||
|  | ||||
|     } | ||||
|  | ||||
|     private void postExecute() { | ||||
|  | ||||
|     } | ||||
|  | ||||
|     private void checkMethod() throws DataException { | ||||
|         if (this.method == null) { | ||||
|             // We need to automatically determine the method | ||||
|             this.method = this.determineMethodAutomatically(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private Method determineMethodAutomatically() throws DataException { | ||||
|         ArbitraryDataReader reader = new ArbitraryDataReader(this.name, ResourceIdType.NAME, this.service, this.identifier); | ||||
|         try { | ||||
|             reader.loadSynchronously(true); | ||||
|         } catch (Exception e) { | ||||
|             // Catch all exceptions if the existing resource cannot be loaded first time | ||||
|             // In these cases it's simplest to just use a PUT transaction | ||||
|             return Method.PUT; | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             // Check layer count | ||||
|             int layerCount = reader.getLayerCount(); | ||||
|             if (layerCount >= MAX_LAYERS) { | ||||
|                 LOGGER.info("Reached maximum layer count ({} / {}) - using PUT", layerCount, MAX_LAYERS); | ||||
|                 return Method.PUT; | ||||
|             } | ||||
|  | ||||
|             // Check size of differences between this layer and previous layer | ||||
|             ArbitraryDataCreatePatch patch = new ArbitraryDataCreatePatch(reader.getFilePath(), this.path, reader.getLatestSignature()); | ||||
|             patch.create(); | ||||
|             long diffSize = FilesystemUtils.getDirectorySize(patch.getFinalPath()); | ||||
|             long existingStateSize = FilesystemUtils.getDirectorySize(reader.getFilePath()); | ||||
|             double difference = (double) diffSize / (double) existingStateSize; | ||||
|             if (difference > MAX_SIZE_DIFF) { | ||||
|                 LOGGER.info("Reached maximum difference ({} / {}) - using PUT", difference, MAX_SIZE_DIFF); | ||||
|                 return Method.PUT; | ||||
|             } | ||||
|  | ||||
|             // Check number of modified files | ||||
|             ArbitraryDataMetadataPatch metadata = patch.getMetadata(); | ||||
|             int totalFileCount = patch.getTotalFileCount(); | ||||
|             int differencesCount = metadata.getFileDifferencesCount(); | ||||
|             difference = (double) differencesCount / (double) totalFileCount; | ||||
|             if (difference > MAX_FILE_DIFF) { | ||||
|                 LOGGER.info("Reached maximum file differences ({} / {}) - using PUT", difference, MAX_FILE_DIFF); | ||||
|                 return Method.PUT; | ||||
|             } | ||||
|  | ||||
|             // Check the patch types | ||||
|             // Limit this check to single file resources only for now | ||||
|             boolean atLeastOnePatch = false; | ||||
|             if (totalFileCount == 1) { | ||||
|                 for (ModifiedPath path : metadata.getModifiedPaths()) { | ||||
|                     if (path.getDiffType() != DiffType.COMPLETE_FILE) { | ||||
|                         atLeastOnePatch = true; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             if (!atLeastOnePatch) { | ||||
|                 LOGGER.info("Patch consists of complete files only - using PUT"); | ||||
|                 return Method.PUT; | ||||
|             } | ||||
|  | ||||
|             // State is appropriate for a PATCH transaction | ||||
|             return Method.PATCH; | ||||
|         } | ||||
|         catch (IOException | DataException e) { | ||||
|             // Handle matching states separately, as it's best to block transactions with duplicate states | ||||
|             if (e.getMessage().equals("Current state matches previous state. Nothing to do.")) { | ||||
|                 throw new DataException(e.getMessage()); | ||||
|             } | ||||
|             LOGGER.info("Caught exception: {}", e.getMessage()); | ||||
|             LOGGER.info("Unable to load existing resource - using PUT to overwrite it."); | ||||
|             return Method.PUT; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void createTransaction() throws DataException { | ||||
|         arbitraryDataFile = null; | ||||
|         try { | ||||
|             Long now = NTP.getTime(); | ||||
|             if (now == null) { | ||||
|                 throw new DataException("NTP time not synced yet"); | ||||
|             } | ||||
|  | ||||
|             // Ensure that this chain supports transactions necessary for complex arbitrary data | ||||
|             int transactionVersion = Transaction.getVersionByTimestamp(now); | ||||
|             if (transactionVersion < MIN_TRANSACTION_VERSION) { | ||||
|                 throw new DataException("Transaction version unsupported on this blockchain."); | ||||
|             } | ||||
|  | ||||
|             if (publicKey58 == null || path == null) { | ||||
|                 throw new DataException("Missing public key or path"); | ||||
|             } | ||||
|             byte[] creatorPublicKey = Base58.decode(publicKey58); | ||||
|             final String creatorAddress = Crypto.toAddress(creatorPublicKey); | ||||
|             byte[] lastReference = repository.getAccountRepository().getLastReference(creatorAddress); | ||||
|             if (lastReference == null) { | ||||
|                 // Use a random last reference on the very first transaction for an account | ||||
|                 // Code copied from CrossChainResource.buildAtMessage() | ||||
|                 // We already require PoW on all arbitrary transactions, so no additional logic is needed | ||||
|                 Random random = new Random(); | ||||
|                 lastReference = new byte[Transformer.SIGNATURE_LENGTH]; | ||||
|                 random.nextBytes(lastReference); | ||||
|             } | ||||
|  | ||||
|             Compression compression = Compression.ZIP; | ||||
|  | ||||
|             // FUTURE? Use zip compression for directories, or no compression for single files | ||||
|             // Compression compression = (path.toFile().isDirectory()) ? Compression.ZIP : Compression.NONE; | ||||
|  | ||||
|             ArbitraryDataWriter arbitraryDataWriter = new ArbitraryDataWriter(path, name, service, identifier, method, compression); | ||||
|             try { | ||||
|                 arbitraryDataWriter.setChunkSize(this.chunkSize); | ||||
|                 arbitraryDataWriter.save(); | ||||
|             } catch (IOException | DataException | InterruptedException | RuntimeException | MissingDataException e) { | ||||
|                 LOGGER.info("Unable to create arbitrary data file: {}", e.getMessage()); | ||||
|                 throw new DataException(e.getMessage()); | ||||
|             } | ||||
|  | ||||
|             // Get main file | ||||
|             arbitraryDataFile = arbitraryDataWriter.getArbitraryDataFile(); | ||||
|             if (arbitraryDataFile == null) { | ||||
|                 throw new DataException("Arbitrary data file is null"); | ||||
|             } | ||||
|  | ||||
|             // Get chunks metadata file | ||||
|             ArbitraryDataFile metadataFile = arbitraryDataFile.getMetadataFile(); | ||||
|             if (metadataFile == null && arbitraryDataFile.chunkCount() > 1) { | ||||
|                 throw new DataException(String.format("Chunks metadata data file is null but there are %d chunks", arbitraryDataFile.chunkCount())); | ||||
|             } | ||||
|  | ||||
|             String digest58 = arbitraryDataFile.digest58(); | ||||
|             if (digest58 == null) { | ||||
|                 LOGGER.error("Unable to calculate file digest"); | ||||
|                 throw new DataException("Unable to calculate file digest"); | ||||
|             } | ||||
|  | ||||
|             final BaseTransactionData baseTransactionData = new BaseTransactionData(now, Group.NO_GROUP, | ||||
|                     lastReference, creatorPublicKey, 0L, null); | ||||
|             final int size = (int) arbitraryDataFile.size(); | ||||
|             final int version = 5; | ||||
|             final int nonce = 0; | ||||
|             byte[] secret = arbitraryDataFile.getSecret(); | ||||
|             final ArbitraryTransactionData.DataType dataType = ArbitraryTransactionData.DataType.DATA_HASH; | ||||
|             final byte[] digest = arbitraryDataFile.digest(); | ||||
|             final byte[] metadataHash = (metadataFile != null) ? metadataFile.getHash() : null; | ||||
|             final List<PaymentData> payments = new ArrayList<>(); | ||||
|  | ||||
|             ArbitraryTransactionData transactionData = new ArbitraryTransactionData(baseTransactionData, | ||||
|                     version, service, nonce, size, name, identifier, method, | ||||
|                     secret, compression, digest, dataType, metadataHash, payments); | ||||
|  | ||||
|             this.arbitraryTransactionData = transactionData; | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             if (arbitraryDataFile != null) { | ||||
|                 arbitraryDataFile.deleteAll(); | ||||
|             } | ||||
|             throw(e); | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     public void computeNonce() throws DataException { | ||||
|         if (this.arbitraryTransactionData == null) { | ||||
|             throw new DataException("Arbitrary transaction data is required to compute nonce"); | ||||
|         } | ||||
|  | ||||
|         ArbitraryTransaction transaction = (ArbitraryTransaction) Transaction.fromData(repository, this.arbitraryTransactionData); | ||||
|         LOGGER.info("Computing nonce..."); | ||||
|         transaction.computeNonce(); | ||||
|  | ||||
|         Transaction.ValidationResult result = transaction.isValidUnconfirmed(); | ||||
|         if (result != Transaction.ValidationResult.OK) { | ||||
|             arbitraryDataFile.deleteAll(); | ||||
|             throw new DataException(String.format("Arbitrary transaction invalid: %s", result)); | ||||
|         } | ||||
|         LOGGER.info("Transaction is valid"); | ||||
|     } | ||||
|  | ||||
|     public ArbitraryTransactionData getArbitraryTransactionData() { | ||||
|         return this.arbitraryTransactionData; | ||||
|     } | ||||
|  | ||||
|     public ArbitraryDataFile getArbitraryDataFile() { | ||||
|         return this.arbitraryDataFile; | ||||
|     } | ||||
|  | ||||
|     public void setChunkSize(int chunkSize) { | ||||
|         this.chunkSize = chunkSize; | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										342
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataWriter.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										342
									
								
								src/main/java/org/qortal/arbitrary/ArbitraryDataWriter.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,342 @@ | ||||
| package org.qortal.arbitrary; | ||||
|  | ||||
| import org.apache.commons.io.FileUtils; | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.arbitrary.exception.MissingDataException; | ||||
| import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata; | ||||
| import org.qortal.arbitrary.misc.Service; | ||||
| import org.qortal.crypto.Crypto; | ||||
| import org.qortal.data.transaction.ArbitraryTransactionData.*; | ||||
| import org.qortal.crypto.AES; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.arbitrary.ArbitraryDataFile.*; | ||||
| import org.qortal.settings.Settings; | ||||
| import org.qortal.utils.Base58; | ||||
| import org.qortal.utils.FilesystemUtils; | ||||
| import org.qortal.utils.ZipUtils; | ||||
|  | ||||
| import javax.crypto.BadPaddingException; | ||||
| import javax.crypto.IllegalBlockSizeException; | ||||
| import javax.crypto.NoSuchPaddingException; | ||||
| import javax.crypto.SecretKey; | ||||
| import java.io.File; | ||||
| import java.io.IOException; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.Path; | ||||
| import java.nio.file.Paths; | ||||
| import java.security.InvalidAlgorithmParameterException; | ||||
| import java.security.InvalidKeyException; | ||||
| import java.security.NoSuchAlgorithmException; | ||||
|  | ||||
| public class ArbitraryDataWriter { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataWriter.class); | ||||
|  | ||||
|     private Path filePath; | ||||
|     private final String name; | ||||
|     private final Service service; | ||||
|     private final String identifier; | ||||
|     private final Method method; | ||||
|     private final Compression compression; | ||||
|  | ||||
|     private int chunkSize = ArbitraryDataFile.CHUNK_SIZE; | ||||
|  | ||||
|     private SecretKey aesKey; | ||||
|     private ArbitraryDataFile arbitraryDataFile; | ||||
|  | ||||
|     // Intermediate paths to cleanup | ||||
|     private Path workingPath; | ||||
|     private Path compressedPath; | ||||
|     private Path encryptedPath; | ||||
|  | ||||
|     public ArbitraryDataWriter(Path filePath, String name, Service service, String identifier, Method method, Compression compression) { | ||||
|         this.filePath = filePath; | ||||
|         this.name = name; | ||||
|         this.service = service; | ||||
|         this.method = method; | ||||
|         this.compression = compression; | ||||
|  | ||||
|         // If identifier is a blank string, or reserved keyword "default", treat it as null | ||||
|         if (identifier == null || identifier.equals("") || identifier.equals("default")) { | ||||
|             identifier = null; | ||||
|         } | ||||
|         this.identifier = identifier; | ||||
|     } | ||||
|  | ||||
|     public void save() throws IOException, DataException, InterruptedException, MissingDataException { | ||||
|         try { | ||||
|             this.preExecute(); | ||||
|             this.validateService(); | ||||
|             this.process(); | ||||
|             this.compress(); | ||||
|             this.encrypt(); | ||||
|             this.split(); | ||||
|             this.createMetadataFile(); | ||||
|             this.validate(); | ||||
|  | ||||
|         } finally { | ||||
|             this.postExecute(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void preExecute() throws DataException { | ||||
|         this.checkEnabled(); | ||||
|  | ||||
|         // Enforce compression when uploading a directory | ||||
|         File file = new File(this.filePath.toString()); | ||||
|         if (file.isDirectory() && compression == Compression.NONE) { | ||||
|             throw new DataException("Unable to upload a directory without compression"); | ||||
|         } | ||||
|  | ||||
|         // Create temporary working directory | ||||
|         this.createWorkingDirectory(); | ||||
|     } | ||||
|  | ||||
|     private void postExecute() throws IOException { | ||||
|         this.cleanupFilesystem(); | ||||
|     } | ||||
|  | ||||
|     private void checkEnabled() throws DataException { | ||||
|         if (!Settings.getInstance().isQdnEnabled()) { | ||||
|             throw new DataException("QDN is disabled in settings"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void createWorkingDirectory() 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 = Base58.encode(Crypto.digest(this.filePath.toString().getBytes())); | ||||
|         Path tempDir = Paths.get(baseDir, "writer", identifier); | ||||
|         try { | ||||
|             Files.createDirectories(tempDir); | ||||
|         } catch (IOException e) { | ||||
|             throw new DataException("Unable to create temp directory"); | ||||
|         } | ||||
|         this.workingPath = tempDir; | ||||
|     } | ||||
|  | ||||
|     private void validateService() throws IOException, DataException { | ||||
|         if (this.service.isValidationRequired()) { | ||||
|             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())); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void process() throws DataException, IOException, MissingDataException { | ||||
|         switch (this.method) { | ||||
|  | ||||
|             case PUT: | ||||
|                 // Nothing to do | ||||
|                 break; | ||||
|  | ||||
|             case PATCH: | ||||
|                 this.processPatch(); | ||||
|                 break; | ||||
|  | ||||
|             default: | ||||
|                 throw new DataException(String.format("Unknown method specified: %s", method.toString())); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void processPatch() throws DataException, IOException, MissingDataException { | ||||
|  | ||||
|         // Build the existing state using past transactions | ||||
|         ArbitraryDataBuilder builder = new ArbitraryDataBuilder(this.name, this.service, this.identifier); | ||||
|         builder.build(); | ||||
|         Path builtPath = builder.getFinalPath(); | ||||
|  | ||||
|         // Obtain the latest signature, so this can be included in the patch | ||||
|         byte[] latestSignature = builder.getLatestSignature(); | ||||
|  | ||||
|         // Compute a diff of the latest changes on top of the previous state | ||||
|         // Then use only the differences as our data payload | ||||
|         ArbitraryDataCreatePatch patch = new ArbitraryDataCreatePatch(builtPath, this.filePath, latestSignature); | ||||
|         patch.create(); | ||||
|         this.filePath = patch.getFinalPath(); | ||||
|  | ||||
|         // Delete the input directory | ||||
|         if (FilesystemUtils.pathInsideDataOrTempPath(builtPath)) { | ||||
|             File directory = new File(builtPath.toString()); | ||||
|             FileUtils.deleteDirectory(directory); | ||||
|         } | ||||
|  | ||||
|         // Validate the patch | ||||
|         this.validatePatch(); | ||||
|     } | ||||
|  | ||||
|     private void validatePatch() throws DataException { | ||||
|         if (this.filePath == null) { | ||||
|             throw new DataException("Null path after creating patch"); | ||||
|         } | ||||
|  | ||||
|         File qortalMetadataDirectoryFile = Paths.get(this.filePath.toString(), ".qortal").toFile(); | ||||
|         if (!qortalMetadataDirectoryFile.exists()) { | ||||
|             throw new DataException("Qortal metadata folder doesn't exist in patch"); | ||||
|         } | ||||
|         if (!qortalMetadataDirectoryFile.isDirectory()) { | ||||
|             throw new DataException("Qortal metadata folder isn't a directory"); | ||||
|         } | ||||
|  | ||||
|         File qortalPatchMetadataFile = Paths.get(this.filePath.toString(), ".qortal", "patch").toFile(); | ||||
|         if (!qortalPatchMetadataFile.exists()) { | ||||
|             throw new DataException("Qortal patch metadata file doesn't exist in patch"); | ||||
|         } | ||||
|         if (!qortalPatchMetadataFile.isFile()) { | ||||
|             throw new DataException("Qortal patch metadata file isn't a file"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void compress() throws InterruptedException, DataException { | ||||
|         // Compress the data if requested | ||||
|         if (this.compression != Compression.NONE) { | ||||
|             this.compressedPath = Paths.get(this.workingPath.toString(), "data.zip"); | ||||
|             try { | ||||
|  | ||||
|                 if (this.compression == Compression.ZIP) { | ||||
|                     LOGGER.info("Compressing..."); | ||||
|                     String enclosingFolderName = "data"; | ||||
|                     ZipUtils.zip(this.filePath.toString(), this.compressedPath.toString(), enclosingFolderName); | ||||
|                 } | ||||
|                 else { | ||||
|                     throw new DataException(String.format("Unknown compression type specified: %s", compression.toString())); | ||||
|                 } | ||||
|                 // FUTURE: other compression types | ||||
|  | ||||
|                 // Delete the input directory | ||||
|                 if (FilesystemUtils.pathInsideDataOrTempPath(this.filePath)) { | ||||
|                     File directory = new File(this.filePath.toString()); | ||||
|                     FileUtils.deleteDirectory(directory); | ||||
|                 } | ||||
|                 // Replace filePath pointer with the zipped file path | ||||
|                 this.filePath = this.compressedPath; | ||||
|  | ||||
|             } catch (IOException | DataException e) { | ||||
|                 throw new DataException("Unable to zip directory", e); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void encrypt() throws DataException { | ||||
|         this.encryptedPath = Paths.get(this.workingPath.toString(), "data.zip.encrypted"); | ||||
|         try { | ||||
|             // Encrypt the file with AES | ||||
|             LOGGER.info("Encrypting..."); | ||||
|             this.aesKey = AES.generateKey(256); | ||||
|             AES.encryptFile("AES/CBC/PKCS5Padding", this.aesKey, this.filePath.toString(), this.encryptedPath.toString()); | ||||
|  | ||||
|             // Delete the input file | ||||
|             if (FilesystemUtils.pathInsideDataOrTempPath(this.filePath)) { | ||||
|                 Files.delete(this.filePath); | ||||
|             } | ||||
|             // Replace filePath pointer with the encrypted file path | ||||
|             this.filePath = this.encryptedPath; | ||||
|  | ||||
|         } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | NoSuchPaddingException | ||||
|                 | BadPaddingException | IllegalBlockSizeException | IOException | InvalidKeyException e) { | ||||
|             throw new DataException(String.format("Unable to encrypt file %s: %s", this.filePath, e.getMessage())); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void split() throws IOException, DataException { | ||||
|         // We don't have a signature yet, so use null to put the file in a generic folder | ||||
|         this.arbitraryDataFile = ArbitraryDataFile.fromPath(this.filePath, null); | ||||
|         if (this.arbitraryDataFile == null) { | ||||
|             throw new IOException("No file available when trying to split"); | ||||
|         } | ||||
|  | ||||
|         int chunkCount = this.arbitraryDataFile.split(this.chunkSize); | ||||
|         if (chunkCount > 0) { | ||||
|             LOGGER.info(String.format("Successfully split into %d chunk%s", chunkCount, (chunkCount == 1 ? "" : "s"))); | ||||
|         } | ||||
|         else { | ||||
|             throw new DataException("Unable to split file into chunks"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void createMetadataFile() throws IOException, DataException { | ||||
|         // If we have at least one chunk, we need to create an index file containing their hashes | ||||
|         if (this.arbitraryDataFile.chunkCount() > 1) { | ||||
|             // Create the JSON file | ||||
|             Path chunkFilePath = Paths.get(this.workingPath.toString(), "metadata.json"); | ||||
|             ArbitraryDataTransactionMetadata chunkMetadata = new ArbitraryDataTransactionMetadata(chunkFilePath); | ||||
|             chunkMetadata.setChunks(this.arbitraryDataFile.chunkHashList()); | ||||
|             chunkMetadata.write(); | ||||
|  | ||||
|             // Create an ArbitraryDataFile from the JSON file (we don't have a signature yet) | ||||
|             ArbitraryDataFile metadataFile = ArbitraryDataFile.fromPath(chunkFilePath, null); | ||||
|             this.arbitraryDataFile.setMetadataFile(metadataFile); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void validate() throws IOException, DataException { | ||||
|         if (this.arbitraryDataFile == null) { | ||||
|             throw new DataException("No file available when validating"); | ||||
|         } | ||||
|         this.arbitraryDataFile.setSecret(this.aesKey.getEncoded()); | ||||
|  | ||||
|         // Validate the file | ||||
|         ValidationResult validationResult = this.arbitraryDataFile.isValid(); | ||||
|         if (validationResult != ValidationResult.OK) { | ||||
|             throw new DataException(String.format("File %s failed validation: %s", this.arbitraryDataFile, validationResult)); | ||||
|         } | ||||
|         LOGGER.info("Whole file hash is valid: {}", this.arbitraryDataFile.digest58()); | ||||
|  | ||||
|         // Validate each chunk | ||||
|         for (ArbitraryDataFileChunk chunk : this.arbitraryDataFile.getChunks()) { | ||||
|             validationResult = chunk.isValid(); | ||||
|             if (validationResult != ValidationResult.OK) { | ||||
|                 throw new DataException(String.format("Chunk %s failed validation: %s", chunk, validationResult)); | ||||
|             } | ||||
|         } | ||||
|         LOGGER.info("Chunk hashes are valid"); | ||||
|  | ||||
|         // Validate chunks metadata file | ||||
|         if (this.arbitraryDataFile.chunkCount() > 1) { | ||||
|             ArbitraryDataFile metadataFile = this.arbitraryDataFile.getMetadataFile(); | ||||
|             if (metadataFile == null || !metadataFile.exists()) { | ||||
|                 throw new DataException("No metadata file available, but there are multiple chunks"); | ||||
|             } | ||||
|             // Read the file | ||||
|             ArbitraryDataTransactionMetadata metadata = new ArbitraryDataTransactionMetadata(metadataFile.getFilePath()); | ||||
|             metadata.read(); | ||||
|             // Check all chunks exist | ||||
|             for (byte[] chunk : this.arbitraryDataFile.chunkHashList()) { | ||||
|                 if (!metadata.containsChunk(chunk)) { | ||||
|                     throw new DataException(String.format("Missing chunk %s in metadata file", Base58.encode(chunk))); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void cleanupFilesystem() throws IOException { | ||||
|         // Clean up | ||||
|         if (FilesystemUtils.pathInsideDataOrTempPath(this.compressedPath)) { | ||||
|             File zippedFile = new File(this.compressedPath.toString()); | ||||
|             if (zippedFile.exists()) { | ||||
|                 zippedFile.delete(); | ||||
|             } | ||||
|         } | ||||
|         if (FilesystemUtils.pathInsideDataOrTempPath(this.encryptedPath)) { | ||||
|             File encryptedFile = new File(this.encryptedPath.toString()); | ||||
|             if (encryptedFile.exists()) { | ||||
|                 encryptedFile.delete(); | ||||
|             } | ||||
|         } | ||||
|         if (FilesystemUtils.pathInsideDataOrTempPath(this.workingPath)) { | ||||
|             FileUtils.deleteDirectory(new File(this.workingPath.toString())); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public ArbitraryDataFile getArbitraryDataFile() { | ||||
|         return this.arbitraryDataFile; | ||||
|     } | ||||
|  | ||||
|     public void setChunkSize(int chunkSize) { | ||||
|         this.chunkSize = chunkSize; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,20 @@ | ||||
| package org.qortal.arbitrary.exception; | ||||
|  | ||||
| public class MissingDataException extends Exception { | ||||
|  | ||||
| 	public MissingDataException() { | ||||
| 	} | ||||
|  | ||||
| 	public MissingDataException(String message) { | ||||
| 		super(message); | ||||
| 	} | ||||
|  | ||||
| 	public MissingDataException(String message, Throwable cause) { | ||||
| 		super(message, cause); | ||||
| 	} | ||||
|  | ||||
| 	public MissingDataException(Throwable cause) { | ||||
| 		super(cause); | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,85 @@ | ||||
| package org.qortal.arbitrary.metadata; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.repository.DataException; | ||||
|  | ||||
| import java.io.BufferedWriter; | ||||
| import java.io.File; | ||||
| import java.io.FileWriter; | ||||
| import java.io.IOException; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.Path; | ||||
|  | ||||
| /** | ||||
|  * ArbitraryDataMetadata | ||||
|  * | ||||
|  * This is a base class to handle reading and writing JSON to the supplied filePath. | ||||
|  * | ||||
|  * It is not usable on its own; it must be subclassed, with two methods overridden: | ||||
|  * | ||||
|  * readJson() - code to unserialize the JSON file | ||||
|  * buildJson() - code to serialize the JSON file | ||||
|  * | ||||
|  */ | ||||
| public class ArbitraryDataMetadata { | ||||
|  | ||||
|     protected static final Logger LOGGER = LogManager.getLogger(ArbitraryDataMetadata.class); | ||||
|  | ||||
|     protected Path filePath; | ||||
|  | ||||
|     protected String jsonString; | ||||
|  | ||||
|     public ArbitraryDataMetadata(Path filePath) { | ||||
|         this.filePath = filePath; | ||||
|     } | ||||
|  | ||||
|     protected void readJson() throws DataException { | ||||
|         // To be overridden | ||||
|     } | ||||
|  | ||||
|     protected void buildJson() { | ||||
|         // To be overridden | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public void read() throws IOException, DataException { | ||||
|         this.loadJson(); | ||||
|         this.readJson(); | ||||
|     } | ||||
|  | ||||
|     public void write() throws IOException, DataException { | ||||
|         this.buildJson(); | ||||
|         this.createParentDirectories(); | ||||
|  | ||||
|         BufferedWriter writer = new BufferedWriter(new FileWriter(this.filePath.toString())); | ||||
|         writer.write(this.jsonString); | ||||
|         writer.newLine(); | ||||
|         writer.close(); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     protected void loadJson() throws IOException { | ||||
|         File metadataFile = new File(this.filePath.toString()); | ||||
|         if (!metadataFile.exists()) { | ||||
|             throw new IOException(String.format("Metadata file doesn't exist: %s", this.filePath.toString())); | ||||
|         } | ||||
|  | ||||
|         this.jsonString = new String(Files.readAllBytes(this.filePath)); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     protected void createParentDirectories() throws DataException { | ||||
|         try { | ||||
|             Files.createDirectories(this.filePath.getParent()); | ||||
|         } catch (IOException e) { | ||||
|             throw new DataException("Unable to create parent directories"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public String getJsonString() { | ||||
|         return this.jsonString; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,69 @@ | ||||
| package org.qortal.arbitrary.metadata; | ||||
|  | ||||
| import org.json.JSONObject; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.utils.Base58; | ||||
|  | ||||
| import java.nio.file.Path; | ||||
|  | ||||
| public class ArbitraryDataMetadataCache extends ArbitraryDataQortalMetadata { | ||||
|  | ||||
|     private byte[] signature; | ||||
|     private long timestamp; | ||||
|  | ||||
|     public ArbitraryDataMetadataCache(Path filePath) { | ||||
|         super(filePath); | ||||
|  | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected String fileName() { | ||||
|         return "cache"; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected void readJson() throws DataException { | ||||
|         if (this.jsonString == null) { | ||||
|             throw new DataException("Patch JSON string is null"); | ||||
|         } | ||||
|  | ||||
|         JSONObject cache = new JSONObject(this.jsonString); | ||||
|         if (cache.has("signature")) { | ||||
|             String sig = cache.getString("signature"); | ||||
|             if (sig != null) { | ||||
|                 this.signature = Base58.decode(sig); | ||||
|             } | ||||
|         } | ||||
|         if (cache.has("timestamp")) { | ||||
|             this.timestamp = cache.getLong("timestamp"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected void buildJson() { | ||||
|         JSONObject patch = new JSONObject(); | ||||
|         patch.put("signature", Base58.encode(this.signature)); | ||||
|         patch.put("timestamp", this.timestamp); | ||||
|  | ||||
|         this.jsonString = patch.toString(2); | ||||
|         LOGGER.trace("Cache metadata: {}", this.jsonString); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public void setSignature(byte[] signature) { | ||||
|         this.signature = signature; | ||||
|     } | ||||
|  | ||||
|     public byte[] getSignature() { | ||||
|         return this.signature; | ||||
|     } | ||||
|  | ||||
|     public void setTimestamp(long timestamp) { | ||||
|         this.timestamp = timestamp; | ||||
|     } | ||||
|  | ||||
|     public long getTimestamp() { | ||||
|         return this.timestamp; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,182 @@ | ||||
| package org.qortal.arbitrary.metadata; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.json.JSONArray; | ||||
| import org.json.JSONObject; | ||||
| import org.qortal.arbitrary.ArbitraryDataDiff.*; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.utils.Base58; | ||||
|  | ||||
| import java.lang.reflect.Field; | ||||
| import java.nio.file.Path; | ||||
| import java.nio.file.Paths; | ||||
| import java.util.ArrayList; | ||||
| import java.util.LinkedHashMap; | ||||
| import java.util.List; | ||||
|  | ||||
| public class ArbitraryDataMetadataPatch extends ArbitraryDataQortalMetadata { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataMetadataPatch.class); | ||||
|  | ||||
|     private List<Path> addedPaths; | ||||
|     private List<ModifiedPath> modifiedPaths; | ||||
|     private List<Path> removedPaths; | ||||
|     private byte[] previousSignature; | ||||
|     private byte[] previousHash; | ||||
|     private byte[] currentHash; | ||||
|  | ||||
|     public ArbitraryDataMetadataPatch(Path filePath) { | ||||
|         super(filePath); | ||||
|  | ||||
|         this.addedPaths = new ArrayList<>(); | ||||
|         this.modifiedPaths = new ArrayList<>(); | ||||
|         this.removedPaths = new ArrayList<>(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected String fileName() { | ||||
|         return "patch"; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected void readJson() throws DataException { | ||||
|         if (this.jsonString == null) { | ||||
|             throw new DataException("Patch JSON string is null"); | ||||
|         } | ||||
|  | ||||
|         JSONObject patch = new JSONObject(this.jsonString); | ||||
|         if (patch.has("prevSig")) { | ||||
|             String prevSig = patch.getString("prevSig"); | ||||
|             if (prevSig != null) { | ||||
|                 this.previousSignature = Base58.decode(prevSig); | ||||
|             } | ||||
|         } | ||||
|         if (patch.has("prevHash")) { | ||||
|             String prevHash = patch.getString("prevHash"); | ||||
|             if (prevHash != null) { | ||||
|                 this.previousHash = Base58.decode(prevHash); | ||||
|             } | ||||
|         } | ||||
|         if (patch.has("curHash")) { | ||||
|             String curHash = patch.getString("curHash"); | ||||
|             if (curHash != null) { | ||||
|                 this.currentHash = Base58.decode(curHash); | ||||
|             } | ||||
|         } | ||||
|         if (patch.has("added")) { | ||||
|             JSONArray added = (JSONArray) patch.get("added"); | ||||
|             if (added != null) { | ||||
|                 for (int i=0; i<added.length(); i++) { | ||||
|                     String pathString = added.getString(i); | ||||
|                     this.addedPaths.add(Paths.get(pathString)); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         if (patch.has("modified")) { | ||||
|             JSONArray modified = (JSONArray) patch.get("modified"); | ||||
|             if (modified != null) { | ||||
|                 for (int i=0; i<modified.length(); i++) { | ||||
|                     JSONObject jsonObject = modified.getJSONObject(i); | ||||
|                     ModifiedPath modifiedPath = new ModifiedPath(jsonObject); | ||||
|                     this.modifiedPaths.add(modifiedPath); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         if (patch.has("removed")) { | ||||
|             JSONArray removed = (JSONArray) patch.get("removed"); | ||||
|             if (removed != null) { | ||||
|                 for (int i=0; i<removed.length(); i++) { | ||||
|                     String pathString = removed.getString(i); | ||||
|                     this.removedPaths.add(Paths.get(pathString)); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected void buildJson() { | ||||
|         JSONObject patch = new JSONObject(); | ||||
|         // Attempt to use a LinkedHashMap so that the order of fields is maintained | ||||
|         try { | ||||
|             Field changeMap = patch.getClass().getDeclaredField("map"); | ||||
|             changeMap.setAccessible(true); | ||||
|             changeMap.set(patch, new LinkedHashMap<>()); | ||||
|             changeMap.setAccessible(false); | ||||
|         } catch (IllegalAccessException | NoSuchFieldException e) { | ||||
|             // Don't worry about failures as this is for optional ordering only | ||||
|         } | ||||
|  | ||||
|         patch.put("prevSig", Base58.encode(this.previousSignature)); | ||||
|         patch.put("prevHash", Base58.encode(this.previousHash)); | ||||
|         patch.put("curHash", Base58.encode(this.currentHash)); | ||||
|         patch.put("added", new JSONArray(this.addedPaths)); | ||||
|         patch.put("removed", new JSONArray(this.removedPaths)); | ||||
|  | ||||
|         JSONArray modifiedPaths = new JSONArray(); | ||||
|         for (ModifiedPath modifiedPath : this.modifiedPaths) { | ||||
|             JSONObject modifiedPathJson = new JSONObject(); | ||||
|             modifiedPathJson.put("path", modifiedPath.getPath()); | ||||
|             modifiedPathJson.put("type", modifiedPath.getDiffType()); | ||||
|             modifiedPaths.put(modifiedPathJson); | ||||
|         } | ||||
|         patch.put("modified", modifiedPaths); | ||||
|  | ||||
|         this.jsonString = patch.toString(2); | ||||
|         LOGGER.debug("Patch metadata: {}", this.jsonString); | ||||
|     } | ||||
|  | ||||
|     public void setAddedPaths(List<Path> addedPaths) { | ||||
|         this.addedPaths = addedPaths; | ||||
|     } | ||||
|  | ||||
|     public List<Path> getAddedPaths() { | ||||
|         return this.addedPaths; | ||||
|     } | ||||
|  | ||||
|     public void setModifiedPaths(List<ModifiedPath> modifiedPaths) { | ||||
|         this.modifiedPaths = modifiedPaths; | ||||
|     } | ||||
|  | ||||
|     public List<ModifiedPath> getModifiedPaths() { | ||||
|         return this.modifiedPaths; | ||||
|     } | ||||
|  | ||||
|     public void setRemovedPaths(List<Path> removedPaths) { | ||||
|         this.removedPaths = removedPaths; | ||||
|     } | ||||
|  | ||||
|     public List<Path> getRemovedPaths() { | ||||
|         return this.removedPaths; | ||||
|     } | ||||
|  | ||||
|     public void setPreviousSignature(byte[] previousSignature) { | ||||
|         this.previousSignature = previousSignature; | ||||
|     } | ||||
|  | ||||
|     public byte[] getPreviousSignature() { | ||||
|         return this.previousSignature; | ||||
|     } | ||||
|  | ||||
|     public void setPreviousHash(byte[] previousHash) { | ||||
|         this.previousHash = previousHash; | ||||
|     } | ||||
|  | ||||
|     public byte[] getPreviousHash() { | ||||
|         return this.previousHash; | ||||
|     } | ||||
|  | ||||
|     public void setCurrentHash(byte[] currentHash) { | ||||
|         this.currentHash = currentHash; | ||||
|     } | ||||
|  | ||||
|     public byte[] getCurrentHash() { | ||||
|         return this.currentHash; | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public int getFileDifferencesCount() { | ||||
|         return this.addedPaths.size() + this.modifiedPaths.size() + this.removedPaths.size(); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,102 @@ | ||||
| package org.qortal.arbitrary.metadata; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.repository.DataException; | ||||
|  | ||||
| import java.io.BufferedWriter; | ||||
| import java.io.File; | ||||
| import java.io.FileWriter; | ||||
| import java.io.IOException; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.Path; | ||||
| import java.nio.file.Paths; | ||||
|  | ||||
| /** | ||||
|  * ArbitraryDataQortalMetadata | ||||
|  * | ||||
|  * This is a base class to handle reading and writing JSON to a .qortal folder | ||||
|  * within the supplied filePath. This is used when storing data against an existing | ||||
|  * arbitrary data file structure. | ||||
|  * | ||||
|  * It is not usable on its own; it must be subclassed, with three methods overridden: | ||||
|  * | ||||
|  * fileName() - the file name to use within the .qortal folder | ||||
|  * readJson() - code to unserialize the JSON file | ||||
|  * buildJson() - code to serialize the JSON file | ||||
|  * | ||||
|  */ | ||||
| public class ArbitraryDataQortalMetadata extends ArbitraryDataMetadata { | ||||
|  | ||||
|     protected static final Logger LOGGER = LogManager.getLogger(ArbitraryDataQortalMetadata.class); | ||||
|  | ||||
|     protected Path filePath; | ||||
|     protected Path qortalDirectoryPath; | ||||
|  | ||||
|     protected String jsonString; | ||||
|  | ||||
|     public ArbitraryDataQortalMetadata(Path filePath) { | ||||
|         super(filePath); | ||||
|  | ||||
|         this.qortalDirectoryPath = Paths.get(filePath.toString(), ".qortal"); | ||||
|     } | ||||
|  | ||||
|     protected String fileName() { | ||||
|         // To be overridden | ||||
|         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 { | ||||
|         this.buildJson(); | ||||
|         this.createParentDirectories(); | ||||
|         this.createQortalDirectory(); | ||||
|  | ||||
|         Path patchPath = Paths.get(this.qortalDirectoryPath.toString(), this.fileName()); | ||||
|         BufferedWriter writer = new BufferedWriter(new FileWriter(patchPath.toString())); | ||||
|         writer.write(this.jsonString); | ||||
|         writer.newLine(); | ||||
|         writer.close(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected void loadJson() throws IOException { | ||||
|         Path path = Paths.get(this.qortalDirectoryPath.toString(), this.fileName()); | ||||
|         File patchFile = new File(path.toString()); | ||||
|         if (!patchFile.exists()) { | ||||
|             throw new IOException(String.format("Patch file doesn't exist: %s", path.toString())); | ||||
|         } | ||||
|  | ||||
|         this.jsonString = new String(Files.readAllBytes(path)); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     protected void createQortalDirectory() throws DataException { | ||||
|         try { | ||||
|             Files.createDirectories(this.qortalDirectoryPath); | ||||
|         } catch (IOException e) { | ||||
|             throw new DataException("Unable to create .qortal directory"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public String getJsonString() { | ||||
|         return this.jsonString; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,78 @@ | ||||
| package org.qortal.arbitrary.metadata; | ||||
|  | ||||
| import org.json.JSONArray; | ||||
| import org.json.JSONObject; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.utils.Base58; | ||||
|  | ||||
| import java.nio.file.Path; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.List; | ||||
|  | ||||
| public class ArbitraryDataTransactionMetadata extends ArbitraryDataMetadata { | ||||
|  | ||||
|     private List<byte[]> chunks; | ||||
|  | ||||
|     public ArbitraryDataTransactionMetadata(Path filePath) { | ||||
|         super(filePath); | ||||
|  | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected void readJson() throws DataException { | ||||
|         if (this.jsonString == null) { | ||||
|             throw new DataException("Transaction metadata JSON string is null"); | ||||
|         } | ||||
|  | ||||
|         List<byte[]> chunksList = new ArrayList<>(); | ||||
|         JSONObject cache = new JSONObject(this.jsonString); | ||||
|         if (cache.has("chunks")) { | ||||
|             JSONArray chunks = cache.getJSONArray("chunks"); | ||||
|             if (chunks != null) { | ||||
|                 for (int i=0; i<chunks.length(); i++) { | ||||
|                     String chunk = chunks.getString(i); | ||||
|                     if (chunk != null) { | ||||
|                         chunksList.add(Base58.decode(chunk)); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             this.chunks = chunksList; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected void buildJson() { | ||||
|         JSONObject outer = new JSONObject(); | ||||
|  | ||||
|         JSONArray chunks = new JSONArray(); | ||||
|         if (this.chunks != null) { | ||||
|             for (byte[] chunk : this.chunks) { | ||||
|                 chunks.put(Base58.encode(chunk)); | ||||
|             } | ||||
|         } | ||||
|         outer.put("chunks", chunks); | ||||
|  | ||||
|         this.jsonString = outer.toString(2); | ||||
|         LOGGER.trace("Transaction metadata: {}", this.jsonString); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public void setChunks(List<byte[]> chunks) { | ||||
|         this.chunks = chunks; | ||||
|     } | ||||
|  | ||||
|     public List<byte[]> getChunks() { | ||||
|         return this.chunks; | ||||
|     } | ||||
|  | ||||
|     public boolean containsChunk(byte[] chunk) { | ||||
|         for (byte[] c : this.chunks) { | ||||
|             if (Arrays.equals(c, chunk)) { | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										131
									
								
								src/main/java/org/qortal/arbitrary/misc/Service.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								src/main/java/org/qortal/arbitrary/misc/Service.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,131 @@ | ||||
| package org.qortal.arbitrary.misc; | ||||
|  | ||||
| import org.json.JSONObject; | ||||
| import org.qortal.arbitrary.ArbitraryDataRenderer; | ||||
| import org.qortal.transaction.Transaction; | ||||
| import org.qortal.utils.FilesystemUtils; | ||||
|  | ||||
| import java.io.IOException; | ||||
| import java.nio.file.Path; | ||||
| import java.nio.file.Paths; | ||||
| import java.util.Arrays; | ||||
| import java.util.List; | ||||
| import java.util.Map; | ||||
|  | ||||
| import static java.util.Arrays.stream; | ||||
| import static java.util.stream.Collectors.toMap; | ||||
|  | ||||
| public enum Service { | ||||
|     AUTO_UPDATE(1, false, null, null), | ||||
|     ARBITRARY_DATA(100, false, null, null), | ||||
|     WEBSITE(200, true, null, null) { | ||||
|         @Override | ||||
|         public ValidationResult validate(Path path) { | ||||
|             // Custom validation function to require an index HTML file in the root directory | ||||
|             List<String> fileNames = ArbitraryDataRenderer.indexFiles(); | ||||
|             String[] files = path.toFile().list(); | ||||
|             if (files != null) { | ||||
|                 for (String file : files) { | ||||
|                     Path fileName = Paths.get(file).getFileName(); | ||||
|                     if (fileName != null && fileNames.contains(fileName.toString())) { | ||||
|                         return ValidationResult.OK; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             return ValidationResult.MISSING_INDEX_FILE; | ||||
|         } | ||||
|     }, | ||||
|     GIT_REPOSITORY(300, false, null, null), | ||||
|     IMAGE(400, true, 10*1024*1024L, null), | ||||
|     THUMBNAIL(410, true, 500*1024L, null), | ||||
|     VIDEO(500, false, null, null), | ||||
|     AUDIO(600, false, null, null), | ||||
|     BLOG(700, false, null, null), | ||||
|     BLOG_POST(777, false, null, null), | ||||
|     BLOG_COMMENT(778, false, null, null), | ||||
|     DOCUMENT(800, false, null, null), | ||||
|     LIST(900, true, null, null), | ||||
|     PLAYLIST(910, true, null, null), | ||||
|     APP(1000, false, null, null), | ||||
|     METADATA(1100, false, null, null), | ||||
|     QORTAL_METADATA(1111, true, 10*1024L, Arrays.asList("title", "description", "tags")); | ||||
|  | ||||
|     public final int value; | ||||
|     private final boolean requiresValidation; | ||||
|     private final Long maxSize; | ||||
|     private final List<String> requiredKeys; | ||||
|  | ||||
|     private static final Map<Integer, Service> map = stream(Service.values()) | ||||
|             .collect(toMap(service -> service.value, service -> service)); | ||||
|  | ||||
|     Service(int value, boolean requiresValidation, Long maxSize, List<String> requiredKeys) { | ||||
|         this.value = value; | ||||
|         this.requiresValidation = requiresValidation; | ||||
|         this.maxSize = maxSize; | ||||
|         this.requiredKeys = requiredKeys; | ||||
|     } | ||||
|  | ||||
|     public ValidationResult validate(Path path) throws IOException { | ||||
|         if (!this.isValidationRequired()) { | ||||
|             return ValidationResult.OK; | ||||
|         } | ||||
|  | ||||
|         byte[] data = FilesystemUtils.getSingleFileContents(path); | ||||
|         long size = FilesystemUtils.getDirectorySize(path); | ||||
|  | ||||
|         // Validate max size if needed | ||||
|         if (this.maxSize != null) { | ||||
|             if (size > this.maxSize) { | ||||
|                 return ValidationResult.EXCEEDS_SIZE_LIMIT; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Validate required keys if needed | ||||
|         if (this.requiredKeys != null) { | ||||
|             if (data == null) { | ||||
|                 return ValidationResult.MISSING_KEYS; | ||||
|             } | ||||
|             JSONObject json = Service.toJsonObject(data); | ||||
|             for (String key : this.requiredKeys) { | ||||
|                 if (!json.has(key)) { | ||||
|                     return ValidationResult.MISSING_KEYS; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Validation passed | ||||
|         return ValidationResult.OK; | ||||
|     } | ||||
|  | ||||
|     public boolean isValidationRequired() { | ||||
|         return this.requiresValidation; | ||||
|     } | ||||
|  | ||||
|     public static Service valueOf(int value) { | ||||
|         return map.get(value); | ||||
|     } | ||||
|  | ||||
|     public static JSONObject toJsonObject(byte[] data) { | ||||
|         String dataString = new String(data); | ||||
|         return new JSONObject(dataString); | ||||
|     } | ||||
|  | ||||
|     public enum ValidationResult { | ||||
|         OK(1), | ||||
|         MISSING_KEYS(2), | ||||
|         EXCEEDS_SIZE_LIMIT(3), | ||||
|         MISSING_INDEX_FILE(4); | ||||
|  | ||||
|         public final int value; | ||||
|  | ||||
|         private static final Map<Integer, Transaction.ValidationResult> map = stream(Transaction.ValidationResult.values()).collect(toMap(result -> result.value, result -> result)); | ||||
|  | ||||
|         ValidationResult(int value) { | ||||
|             this.value = value; | ||||
|         } | ||||
|  | ||||
|         public static Transaction.ValidationResult valueOf(int value) { | ||||
|             return map.get(value); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										229
									
								
								src/main/java/org/qortal/arbitrary/patch/UnifiedDiffPatch.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										229
									
								
								src/main/java/org/qortal/arbitrary/patch/UnifiedDiffPatch.java
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,229 @@ | ||||
| package org.qortal.arbitrary.patch; | ||||
|  | ||||
| import com.github.difflib.DiffUtils; | ||||
| import com.github.difflib.UnifiedDiffUtils; | ||||
| import com.github.difflib.patch.Patch; | ||||
| import com.github.difflib.patch.PatchFailedException; | ||||
| import org.apache.commons.io.FileUtils; | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.crypto.Crypto; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.settings.Settings; | ||||
| import org.qortal.utils.FilesystemUtils; | ||||
|  | ||||
| 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; | ||||
| import java.util.Arrays; | ||||
| import java.util.List; | ||||
| import java.util.UUID; | ||||
|  | ||||
| public class UnifiedDiffPatch { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(UnifiedDiffPatch.class); | ||||
|  | ||||
|     private final Path before; | ||||
|     private final Path after; | ||||
|     private final Path destination; | ||||
|  | ||||
|     private String identifier; | ||||
|     private Path validationPath; | ||||
|  | ||||
|     public UnifiedDiffPatch(Path before, Path after, Path destination) { | ||||
|         this.before = before; | ||||
|         this.after = after; | ||||
|         this.destination = destination; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create a patch based on the differences in path "after" | ||||
|      * compared with base path "before", outputting the patch | ||||
|      * to the "destination" path. | ||||
|      * | ||||
|      * @throws IOException | ||||
|      */ | ||||
|     public void create() throws IOException { | ||||
|         if (!Files.exists(before)) { | ||||
|             throw new IOException(String.format("File not found (before): %s", before.toString())); | ||||
|         } | ||||
|         if (!Files.exists(after)) { | ||||
|             throw new IOException(String.format("File not found (after): %s", after.toString())); | ||||
|         } | ||||
|  | ||||
|         // Ensure parent folders exist in the destination | ||||
|         File file = new File(destination.toString()); | ||||
|         File parent = file.getParentFile(); | ||||
|         if (parent != null) { | ||||
|             parent.mkdirs(); | ||||
|         } | ||||
|  | ||||
|         // Delete an existing file if it exists | ||||
|         File destFile = destination.toFile(); | ||||
|         if (destFile.exists() && destFile.isFile()) { | ||||
|             Files.delete(destination); | ||||
|         } | ||||
|  | ||||
|         // Load the two files into memory | ||||
|         List<String> original = FileUtils.readLines(before.toFile(), StandardCharsets.UTF_8); | ||||
|         List<String> revised = FileUtils.readLines(after.toFile(), StandardCharsets.UTF_8); | ||||
|  | ||||
|         // Check if the original file ends with a newline | ||||
|         boolean endsWithNewline = FilesystemUtils.fileEndsWithNewline(before); | ||||
|  | ||||
|         // Generate diff information | ||||
|         Patch<String> diff = DiffUtils.diff(original, revised); | ||||
|  | ||||
|         // Generate unified diff format | ||||
|         String originalFileName = before.getFileName().toString(); | ||||
|         String revisedFileName = after.getFileName().toString(); | ||||
|         List<String> unifiedDiff = UnifiedDiffUtils.generateUnifiedDiff(originalFileName, revisedFileName, original, diff, 0); | ||||
|  | ||||
|         // Write the diff to the destination directory | ||||
|         FileWriter fileWriter = new FileWriter(destination.toString(), true); | ||||
|         BufferedWriter writer = new BufferedWriter(fileWriter); | ||||
|         for (int i=0; i<unifiedDiff.size(); i++) { | ||||
|             String line = unifiedDiff.get(i); | ||||
|             writer.append(line); | ||||
|             // Add a newline if this isn't the last line, or the original ended with a newline | ||||
|             if (i < unifiedDiff.size()-1 || endsWithNewline) { | ||||
|                 writer.newLine(); | ||||
|             } | ||||
|         } | ||||
|         writer.flush(); | ||||
|         writer.close(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Validate the patch at the "destination" path to ensure | ||||
|      * it works correctly and is smaller than the original file | ||||
|      * | ||||
|      * @return true if valid, false if invalid | ||||
|      */ | ||||
|     public boolean isValid() throws DataException { | ||||
|         this.createRandomIdentifier(); | ||||
|         this.createTempValidationDirectory(); | ||||
|  | ||||
|         // Merge the patch with the original path | ||||
|         Path tempPath = Paths.get(this.validationPath.toString(), this.identifier); | ||||
|  | ||||
|         try { | ||||
|             UnifiedDiffPatch unifiedDiffPatch = new UnifiedDiffPatch(before, destination, tempPath); | ||||
|             unifiedDiffPatch.apply(null); | ||||
|  | ||||
|             byte[] inputDigest = Crypto.digest(after.toFile()); | ||||
|             byte[] outputDigest = Crypto.digest(tempPath.toFile()); | ||||
|             if (Arrays.equals(inputDigest, outputDigest)) { | ||||
|                 // Patch is valid, but we might want to reject if it's larger than the original file | ||||
|                 long originalSize = Files.size(after); | ||||
|                 long patchSize = Files.size(destination); | ||||
|                 if (patchSize < originalSize) { | ||||
|                     // Patch file is smaller than the original file size, so treat it as valid | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|             else { | ||||
|                 LOGGER.info("Checksum mismatch when verifying patch for file {}", destination.toString()); | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|         } | ||||
|         catch (IOException e) { | ||||
|             LOGGER.info("Failed to compute merge for file {}: {}", destination.toString(), e.getMessage()); | ||||
|         } | ||||
|         finally { | ||||
|             try { | ||||
|                 Files.delete(tempPath); | ||||
|             } catch (IOException e) { | ||||
|                 // Not important - will be cleaned up later | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Apply a patch at path "after" on top of base path "before", | ||||
|      * outputting the combined results to the "destination" path. | ||||
|      * If before and after are directories, a relative path suffix | ||||
|      * can be used to specify the file within these folder structures. | ||||
|      * | ||||
|      * @param pathSuffix - a file path to append to the base paths, or null if the base paths are already files | ||||
|      * @throws IOException | ||||
|      */ | ||||
|     public void apply(Path pathSuffix) throws IOException, DataException { | ||||
|         Path originalPath = this.before; | ||||
|         Path patchPath = this.after; | ||||
|         Path mergePath = this.destination; | ||||
|  | ||||
|         // If a path has been supplied, we need to append it to the base paths | ||||
|         if (pathSuffix != null) { | ||||
|             originalPath = Paths.get(this.before.toString(), pathSuffix.toString()); | ||||
|             patchPath = Paths.get(this.after.toString(), pathSuffix.toString()); | ||||
|             mergePath = Paths.get(this.destination.toString(), pathSuffix.toString()); | ||||
|         } | ||||
|  | ||||
|         if (!patchPath.toFile().exists()) { | ||||
|             throw new DataException("Patch file doesn't exist, but its path was included in modifiedPaths"); | ||||
|         } | ||||
|  | ||||
|         // Delete an existing file, as we are starting from a duplicate of pathBefore | ||||
|         File destFile = mergePath.toFile(); | ||||
|         if (destFile.exists() && destFile.isFile()) { | ||||
|             Files.delete(mergePath); | ||||
|         } | ||||
|  | ||||
|         List<String> originalContents = FileUtils.readLines(originalPath.toFile(), StandardCharsets.UTF_8); | ||||
|         List<String> patchContents = FileUtils.readLines(patchPath.toFile(), StandardCharsets.UTF_8); | ||||
|  | ||||
|         // Check if the patch file (and therefore the original file) ends with a newline | ||||
|         boolean endsWithNewline = FilesystemUtils.fileEndsWithNewline(patchPath); | ||||
|  | ||||
|         // At first, parse the unified diff file and get the patch | ||||
|         Patch<String> patch = UnifiedDiffUtils.parseUnifiedDiff(patchContents); | ||||
|  | ||||
|         // Then apply the computed patch to the given text | ||||
|         try { | ||||
|             List<String> patchedContents = DiffUtils.patch(originalContents, patch); | ||||
|  | ||||
|             // Write the patched file to the merge directory | ||||
|             FileWriter fileWriter = new FileWriter(mergePath.toString(), true); | ||||
|             BufferedWriter writer = new BufferedWriter(fileWriter); | ||||
|             for (int i=0; i<patchedContents.size(); i++) { | ||||
|                 String line = patchedContents.get(i); | ||||
|                 writer.append(line); | ||||
|                 // Add a newline if this isn't the last line, or the original ended with a newline | ||||
|                 if (i < patchedContents.size()-1 || endsWithNewline) { | ||||
|                     writer.newLine(); | ||||
|                 } | ||||
|             } | ||||
|             writer.flush(); | ||||
|             writer.close(); | ||||
|  | ||||
|         } catch (PatchFailedException e) { | ||||
|             throw new DataException(String.format("Failed to apply patch for path %s: %s", pathSuffix, e.getMessage())); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void createRandomIdentifier() { | ||||
|         this.identifier = UUID.randomUUID().toString(); | ||||
|     } | ||||
|  | ||||
|     private void createTempValidationDirectory() 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(); | ||||
|         Path tempDir = Paths.get(baseDir, "diff", "validate"); | ||||
|         try { | ||||
|             Files.createDirectories(tempDir); | ||||
|         } catch (IOException e) { | ||||
|             throw new DataException("Unable to create temp directory"); | ||||
|         } | ||||
|         this.validationPath = tempDir; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,5 +1,7 @@ | ||||
| package org.qortal.at; | ||||
|  | ||||
| import java.util.Arrays; | ||||
| import java.util.Collections; | ||||
| import java.util.List; | ||||
|  | ||||
| import org.ciyam.at.MachineState; | ||||
| @@ -56,12 +58,12 @@ public class AT { | ||||
|  | ||||
| 		this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, codeBytes, codeHash, | ||||
| 				machineState.isSleeping(), machineState.getSleepUntilHeight(), machineState.isFinished(), machineState.hadFatalError(), | ||||
| 				machineState.isFrozen(), machineState.getFrozenBalance()); | ||||
| 				machineState.isFrozen(), machineState.getFrozenBalance(), null); | ||||
|  | ||||
| 		byte[] stateData = machineState.toBytes(); | ||||
| 		byte[] stateHash = Crypto.digest(stateData); | ||||
|  | ||||
| 		this.atStateData = new ATStateData(atAddress, height, stateData, stateHash, 0L, true); | ||||
| 		this.atStateData = new ATStateData(atAddress, height, stateData, stateHash, 0L, true, null); | ||||
| 	} | ||||
|  | ||||
| 	// Getters / setters | ||||
| @@ -84,13 +86,28 @@ public class AT { | ||||
| 		this.repository.getATRepository().delete(this.atData.getATAddress()); | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Potentially execute AT. | ||||
| 	 * <p> | ||||
| 	 * Note that sleep-until-message support might set/reset | ||||
| 	 * sleep-related flags/values. | ||||
| 	 * <p> | ||||
| 	 * {@link #getATStateData()} will return null if nothing happened. | ||||
| 	 * <p> | ||||
| 	 * @param blockHeight | ||||
| 	 * @param blockTimestamp | ||||
| 	 * @return AT-generated transactions, possibly empty | ||||
| 	 * @throws DataException | ||||
| 	 */ | ||||
| 	public List<AtTransaction> run(int blockHeight, long blockTimestamp) throws DataException { | ||||
| 		String atAddress = this.atData.getATAddress(); | ||||
|  | ||||
| 		QortalATAPI api = new QortalATAPI(repository, this.atData, blockTimestamp); | ||||
| 		QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); | ||||
|  | ||||
| 		byte[] codeBytes = this.atData.getCodeBytes(); | ||||
| 		if (!api.willExecute(blockHeight)) | ||||
| 			// this.atStateData will be null | ||||
| 			return Collections.emptyList(); | ||||
|  | ||||
| 		// Fetch latest ATStateData for this AT | ||||
| 		ATStateData latestAtStateData = this.repository.getATRepository().getLatestATState(atAddress); | ||||
| @@ -100,8 +117,10 @@ public class AT { | ||||
| 			throw new IllegalStateException("No previous AT state data found"); | ||||
|  | ||||
| 		// [Re]create AT machine state using AT state data or from scratch as applicable | ||||
| 		byte[] codeBytes = this.atData.getCodeBytes(); | ||||
| 		MachineState state = MachineState.fromBytes(api, loggerFactory, latestAtStateData.getStateData(), codeBytes); | ||||
| 		try { | ||||
| 			api.preExecute(state); | ||||
| 			state.execute(); | ||||
| 		} catch (Exception e) { | ||||
| 			throw new DataException(String.format("Uncaught exception while running AT '%s'", atAddress), e); | ||||
| @@ -109,9 +128,18 @@ public class AT { | ||||
|  | ||||
| 		byte[] stateData = state.toBytes(); | ||||
| 		byte[] stateHash = Crypto.digest(stateData); | ||||
| 		long atFees = api.calcFinalFees(state); | ||||
|  | ||||
| 		this.atStateData = new ATStateData(atAddress, blockHeight, stateData, stateHash, atFees, false); | ||||
| 		// Nothing happened? | ||||
| 		if (state.getSteps() == 0 && Arrays.equals(stateHash, latestAtStateData.getStateHash())) | ||||
| 			// We currently want to execute frozen ATs, to maintain backwards support. | ||||
| 			if (state.isFrozen() == false) | ||||
| 				// this.atStateData will be null | ||||
| 				return Collections.emptyList(); | ||||
|  | ||||
| 		long atFees = api.calcFinalFees(state); | ||||
| 		Long sleepUntilMessageTimestamp = this.atData.getSleepUntilMessageTimestamp(); | ||||
|  | ||||
| 		this.atStateData = new ATStateData(atAddress, blockHeight, stateData, stateHash, atFees, false, sleepUntilMessageTimestamp); | ||||
|  | ||||
| 		return api.getTransactions(); | ||||
| 	} | ||||
| @@ -130,6 +158,10 @@ public class AT { | ||||
| 		this.atData.setHadFatalError(state.hadFatalError()); | ||||
| 		this.atData.setIsFrozen(state.isFrozen()); | ||||
| 		this.atData.setFrozenBalance(state.getFrozenBalance()); | ||||
|  | ||||
| 		// Special sleep-until-message support | ||||
| 		this.atData.setSleepUntilMessageTimestamp(this.atStateData.getSleepUntilMessageTimestamp()); | ||||
|  | ||||
| 		this.repository.getATRepository().save(this.atData); | ||||
| 	} | ||||
|  | ||||
| @@ -157,6 +189,10 @@ public class AT { | ||||
| 		this.atData.setHadFatalError(state.hadFatalError()); | ||||
| 		this.atData.setIsFrozen(state.isFrozen()); | ||||
| 		this.atData.setFrozenBalance(state.getFrozenBalance()); | ||||
|  | ||||
| 		// Special sleep-until-message support | ||||
| 		this.atData.setSleepUntilMessageTimestamp(previousStateData.getSleepUntilMessageTimestamp()); | ||||
|  | ||||
| 		this.repository.getATRepository().save(this.atData); | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -32,6 +32,7 @@ import org.qortal.group.Group; | ||||
| import org.qortal.repository.ATRepository; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.ATRepository.NextTransactionInfo; | ||||
| import org.qortal.transaction.AtTransaction; | ||||
| import org.qortal.transaction.Transaction.TransactionType; | ||||
| import org.qortal.utils.Base58; | ||||
| @@ -74,8 +75,45 @@ public class QortalATAPI extends API { | ||||
| 		return this.transactions; | ||||
| 	} | ||||
|  | ||||
| 	public long calcFinalFees(MachineState state) { | ||||
| 		return state.getSteps() * this.ciyamAtSettings.feePerStep; | ||||
| 	public boolean willExecute(int blockHeight) throws DataException { | ||||
| 		// Sleep-until-message/height checking | ||||
| 		Long sleepUntilMessageTimestamp = this.atData.getSleepUntilMessageTimestamp(); | ||||
|  | ||||
| 		if (sleepUntilMessageTimestamp != null) { | ||||
| 			// Quicker to check height, if sleep-until-height also active | ||||
| 			Integer sleepUntilHeight = this.atData.getSleepUntilHeight(); | ||||
|  | ||||
| 			boolean wakeDueToHeight = sleepUntilHeight != null && sleepUntilHeight != 0 && blockHeight >= sleepUntilHeight; | ||||
|  | ||||
| 			boolean wakeDueToMessage = false; | ||||
| 			if (!wakeDueToHeight) { | ||||
| 				// No avoiding asking repository | ||||
| 				Timestamp previousTxTimestamp = new Timestamp(sleepUntilMessageTimestamp); | ||||
| 				NextTransactionInfo nextTransactionInfo = this.repository.getATRepository().findNextTransaction(this.atData.getATAddress(), | ||||
| 						previousTxTimestamp.blockHeight, | ||||
| 						previousTxTimestamp.transactionSequence); | ||||
|  | ||||
| 				wakeDueToMessage = nextTransactionInfo != null; | ||||
| 			} | ||||
|  | ||||
| 			// Can we skip? | ||||
| 			if (!wakeDueToHeight && !wakeDueToMessage) | ||||
| 				return false; | ||||
| 		} | ||||
|  | ||||
| 		return true; | ||||
| 	} | ||||
|  | ||||
| 	public void preExecute(MachineState state) { | ||||
| 		// Sleep-until-message/height checking | ||||
| 		Long sleepUntilMessageTimestamp = this.atData.getSleepUntilMessageTimestamp(); | ||||
|  | ||||
| 		if (sleepUntilMessageTimestamp != null) { | ||||
| 			// We've passed checks, so clear sleep-related flags/values | ||||
| 			this.setIsSleeping(state, false); | ||||
| 			this.setSleepUntilHeight(state, 0); | ||||
| 			this.atData.setSleepUntilMessageTimestamp(null); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Inherited methods from CIYAM AT API | ||||
| @@ -412,6 +450,10 @@ public class QortalATAPI extends API { | ||||
|  | ||||
| 	// Utility methods | ||||
|  | ||||
| 	public long calcFinalFees(MachineState state) { | ||||
| 		return state.getSteps() * this.ciyamAtSettings.feePerStep; | ||||
| 	} | ||||
|  | ||||
| 	/** Returns partial transaction signature, used to verify we're operating on the same transaction and not naively using block height & sequence. */ | ||||
| 	public static byte[] partialSignature(byte[] fullSignature) { | ||||
| 		return Arrays.copyOfRange(fullSignature, 8, 32); | ||||
| @@ -460,6 +502,15 @@ public class QortalATAPI extends API { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	/*package*/ void sleepUntilMessageOrHeight(MachineState state, long txTimestamp, Long sleepUntilHeight) { | ||||
| 		this.setIsSleeping(state, true); | ||||
|  | ||||
| 		this.atData.setSleepUntilMessageTimestamp(txTimestamp); | ||||
|  | ||||
| 		if (sleepUntilHeight != null) | ||||
| 			this.setSleepUntilHeight(state, sleepUntilHeight.intValue()); | ||||
| 	} | ||||
|  | ||||
| 	/** Returns AT's account */ | ||||
| 	/* package */ Account getATAccount() { | ||||
| 		return new Account(this.repository, this.atData.getATAddress()); | ||||
|   | ||||
| @@ -84,6 +84,43 @@ public enum QortalFunctionCode { | ||||
| 			api.setB(state, bBytes); | ||||
| 		} | ||||
| 	}, | ||||
| 	/** | ||||
| 	 * Sleep AT until a new message arrives after 'tx-timestamp'.<br> | ||||
| 	 * <tt>0x0503 tx-timestamp</tt> | ||||
| 	 */ | ||||
| 	SLEEP_UNTIL_MESSAGE(0x0503, 1, false) { | ||||
| 		@Override | ||||
| 		protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException { | ||||
| 			if (functionData.value1 <= 0) | ||||
| 				return; | ||||
|  | ||||
| 			long txTimestamp = functionData.value1; | ||||
|  | ||||
| 			QortalATAPI api = (QortalATAPI) state.getAPI(); | ||||
| 			api.sleepUntilMessageOrHeight(state, txTimestamp, null); | ||||
| 		} | ||||
| 	}, | ||||
| 	/** | ||||
| 	 * Sleep AT until a new message arrives, after 'tx-timestamp', or height reached.<br> | ||||
| 	 * <tt>0x0504 tx-timestamp height</tt> | ||||
| 	 */ | ||||
| 	SLEEP_UNTIL_MESSAGE_OR_HEIGHT(0x0504, 2, false) { | ||||
| 		@Override | ||||
| 		protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException { | ||||
| 			if (functionData.value1 <= 0) | ||||
| 				return; | ||||
|  | ||||
| 			long txTimestamp = functionData.value1; | ||||
|  | ||||
| 			if (functionData.value2 <= 0) | ||||
| 				return; | ||||
|  | ||||
| 			long sleepUntilHeight = functionData.value2; | ||||
|  | ||||
| 			QortalATAPI api = (QortalATAPI) state.getAPI(); | ||||
| 			api.sleepUntilMessageOrHeight(state, txTimestamp, sleepUntilHeight); | ||||
| 		} | ||||
| 	}, | ||||
| 	/** | ||||
| 	 * Convert address in B to 20-byte value in LSB of B1, and all of B2 & B3.<br> | ||||
| 	 * <tt>0x0510</tt> | ||||
|   | ||||
| @@ -232,7 +232,7 @@ public class Block { | ||||
|  | ||||
| 	// Other useful constants | ||||
|  | ||||
| 	private static final BigInteger MAX_DISTANCE; | ||||
| 	public static final BigInteger MAX_DISTANCE; | ||||
| 	static { | ||||
| 		byte[] maxValue = new byte[Transformer.PUBLIC_KEY_LENGTH]; | ||||
| 		Arrays.fill(maxValue, (byte) 0xFF); | ||||
| @@ -476,6 +476,16 @@ public class Block { | ||||
| 		return this.minter; | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	public void setRepository(Repository repository) throws DataException { | ||||
| 		this.repository = repository; | ||||
|  | ||||
| 		for (Transaction transaction : this.getTransactions()) { | ||||
| 			transaction.setRepository(repository); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	// More information | ||||
|  | ||||
| 	/** | ||||
| @@ -524,8 +534,10 @@ public class Block { | ||||
| 		long nonAtTransactionCount = transactionsData.stream().filter(transactionData -> transactionData.getType() != TransactionType.AT).count(); | ||||
|  | ||||
| 		// The number of non-AT transactions fetched from repository should correspond with Block's transactionCount | ||||
| 		if (nonAtTransactionCount != this.blockData.getTransactionCount()) | ||||
| 		if (nonAtTransactionCount != this.blockData.getTransactionCount()) { | ||||
| 			LOGGER.error(() -> String.format("Block's transactions from repository (%d) do not match block's transaction count (%d)", nonAtTransactionCount, this.blockData.getTransactionCount())); | ||||
| 			throw new IllegalStateException("Block's transactions from repository do not match block's transaction count"); | ||||
| 		} | ||||
|  | ||||
| 		this.transactions = new ArrayList<>(); | ||||
|  | ||||
| @@ -831,7 +843,7 @@ public class Block { | ||||
| 			if (NTP.getTime() >= BlockChain.getInstance().getCalcChainWeightTimestamp() && parentHeight >= maxHeight) | ||||
| 				break; | ||||
| 		} | ||||
| 		LOGGER.debug(String.format("Chain weight calculation was based on %d blocks", blockCount)); | ||||
| 		LOGGER.trace(String.format("Chain weight calculation was based on %d blocks", blockCount)); | ||||
|  | ||||
| 		return cumulativeWeight; | ||||
| 	} | ||||
| @@ -1092,9 +1104,14 @@ public class Block { | ||||
| 			// Create repository savepoint here so we can rollback to it after testing transactions | ||||
| 			repository.setSavepoint(); | ||||
|  | ||||
| 			if (this.blockData.getHeight() == 212937) | ||||
| 			if (this.blockData.getHeight() == 212937) { | ||||
| 				// Apply fix for block 212937 but fix will be rolled back before we exit method | ||||
| 				Block212937.processFix(this); | ||||
| 			} | ||||
| 			else if (InvalidNameRegistrationBlocks.isAffectedBlock(this.blockData.getHeight())) { | ||||
| 				// Apply fix for affected name registration blocks, but fix will be rolled back before we exit method | ||||
| 				InvalidNameRegistrationBlocks.processFix(this); | ||||
| 			} | ||||
|  | ||||
| 			for (Transaction transaction : this.getTransactions()) { | ||||
| 				TransactionData transactionData = transaction.getTransactionData(); | ||||
| @@ -1133,7 +1150,7 @@ public class Block { | ||||
| 				// Check transaction can even be processed | ||||
| 				validationResult = transaction.isProcessable(); | ||||
| 				if (validationResult != Transaction.ValidationResult.OK) { | ||||
| 					LOGGER.debug(String.format("Error during transaction validation, tx %s: %s", Base58.encode(transactionData.getSignature()), validationResult.name())); | ||||
| 					LOGGER.info(String.format("Error during transaction validation, tx %s: %s", Base58.encode(transactionData.getSignature()), validationResult.name())); | ||||
| 					return ValidationResult.TRANSACTION_INVALID; | ||||
| 				} | ||||
|  | ||||
| @@ -1247,12 +1264,13 @@ public class Block { | ||||
| 		for (ATData atData : executableATs) { | ||||
| 			AT at = new AT(this.repository, atData); | ||||
| 			List<AtTransaction> atTransactions = at.run(this.blockData.getHeight(), this.blockData.getTimestamp()); | ||||
| 			ATStateData atStateData = at.getATStateData(); | ||||
| 			// Didn't execute? (e.g. sleeping) | ||||
| 			if (atStateData == null) | ||||
| 				continue; | ||||
|  | ||||
| 			allAtTransactions.addAll(atTransactions); | ||||
|  | ||||
| 			ATStateData atStateData = at.getATStateData(); | ||||
| 			this.ourAtStates.add(atStateData); | ||||
|  | ||||
| 			this.ourAtFees += atStateData.getFees(); | ||||
| 		} | ||||
|  | ||||
| @@ -1281,6 +1299,21 @@ public class Block { | ||||
| 		return mintingAccount.canMint(); | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Pre-process block, and its transactions. | ||||
| 	 * This allows for any database integrity checks prior to validation. | ||||
| 	 * This is called before isValid() and process() | ||||
| 	 * | ||||
| 	 * @throws DataException | ||||
| 	 */ | ||||
| 	public void preProcess() throws DataException { | ||||
| 		List<Transaction> blocksTransactions = this.getTransactions(); | ||||
|  | ||||
| 		for (Transaction transaction : blocksTransactions) { | ||||
| 			transaction.preProcess(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Process block, and its transactions, adding them to the blockchain. | ||||
| 	 *  | ||||
| @@ -1998,6 +2031,10 @@ public class Block { | ||||
|  | ||||
| 	private void logDebugInfo() { | ||||
| 		try { | ||||
| 			// Avoid calculations if possible. We have to check against INFO here, since Level.isMoreSpecificThan() confusingly uses <= rather than just < | ||||
| 			if (LOGGER.getLevel().isMoreSpecificThan(Level.INFO)) | ||||
| 				return; | ||||
|  | ||||
| 			if (this.repository == null || this.getMinter() == null || this.getBlockData() == null) | ||||
| 				return; | ||||
|  | ||||
| @@ -2007,9 +2044,10 @@ public class Block { | ||||
| 			LOGGER.debug(String.format("Timestamp: %d", this.getBlockData().getTimestamp())); | ||||
| 			LOGGER.debug(String.format("Minter level: %d", minterLevel)); | ||||
| 			LOGGER.debug(String.format("Online accounts: %d", this.getBlockData().getOnlineAccountsCount())); | ||||
| 			LOGGER.debug(String.format("AT count: %d", this.getBlockData().getATCount())); | ||||
|  | ||||
| 			BlockSummaryData blockSummaryData = new BlockSummaryData(this.getBlockData()); | ||||
| 			if (this.getParent() == null || this.getParent().getSignature() == null || blockSummaryData == null) | ||||
| 			if (this.getParent() == null || this.getParent().getSignature() == null || blockSummaryData == null || minterLevel == 0) | ||||
| 				return; | ||||
|  | ||||
| 			blockSummaryData.setMinterLevel(minterLevel); | ||||
|   | ||||
| @@ -4,10 +4,7 @@ import java.io.File; | ||||
| import java.io.FileNotFoundException; | ||||
| import java.io.InputStream; | ||||
| import java.sql.SQLException; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Collections; | ||||
| import java.util.List; | ||||
| import java.util.Map; | ||||
| import java.util.*; | ||||
| import java.util.concurrent.locks.ReentrantLock; | ||||
|  | ||||
| import javax.xml.bind.JAXBContext; | ||||
| @@ -27,11 +24,9 @@ import org.eclipse.persistence.jaxb.UnmarshallerProperties; | ||||
| import org.qortal.controller.Controller; | ||||
| import org.qortal.data.block.BlockData; | ||||
| import org.qortal.network.Network; | ||||
| import org.qortal.repository.BlockRepository; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.RepositoryManager; | ||||
| import org.qortal.repository.*; | ||||
| import org.qortal.settings.Settings; | ||||
| import org.qortal.utils.Base58; | ||||
| import org.qortal.utils.StringLongMapXmlAdapter; | ||||
|  | ||||
| /** | ||||
| @@ -73,7 +68,8 @@ public class BlockChain { | ||||
| 		atFindNextTransactionFix, | ||||
| 		newBlockSigHeight, | ||||
| 		shareBinFix, | ||||
| 		calcChainWeightTimestamp; | ||||
| 		calcChainWeightTimestamp, | ||||
| 		transactionV5Timestamp; | ||||
| 	} | ||||
|  | ||||
| 	/** Map of which blockchain features are enabled when (height/timestamp) */ | ||||
| @@ -146,7 +142,8 @@ public class BlockChain { | ||||
| 	} | ||||
| 	private List<BlockTimingByHeight> blockTimingsByHeight; | ||||
|  | ||||
| 	private int minAccountLevelToMint = 1; | ||||
| 	private int minAccountLevelToMint; | ||||
| 	private int minAccountLevelForBlockSubmissions; | ||||
| 	private int minAccountLevelToRewardShare; | ||||
| 	private int maxRewardSharesPerMintingAccount; | ||||
| 	private int founderEffectiveMintingLevel; | ||||
| @@ -349,6 +346,10 @@ public class BlockChain { | ||||
| 		return this.minAccountLevelToMint; | ||||
| 	} | ||||
|  | ||||
| 	public int getMinAccountLevelForBlockSubmissions() { | ||||
| 		return this.minAccountLevelForBlockSubmissions; | ||||
| 	} | ||||
|  | ||||
| 	public int getMinAccountLevelToRewardShare() { | ||||
| 		return this.minAccountLevelToRewardShare; | ||||
| 	} | ||||
| @@ -391,6 +392,10 @@ public class BlockChain { | ||||
| 		return this.featureTriggers.get(FeatureTrigger.calcChainWeightTimestamp.name()).longValue(); | ||||
| 	} | ||||
|  | ||||
| 	public long getTransactionV5Timestamp() { | ||||
| 		return this.featureTriggers.get(FeatureTrigger.transactionV5Timestamp.name()).longValue(); | ||||
| 	} | ||||
|  | ||||
| 	// More complex getters for aspects that change by height or timestamp | ||||
|  | ||||
| 	public long getRewardAtHeight(int ourHeight) { | ||||
| @@ -506,29 +511,105 @@ public class BlockChain { | ||||
| 	 * @throws SQLException | ||||
| 	 */ | ||||
| 	public static void validate() throws DataException { | ||||
| 		// Check first block is Genesis Block | ||||
| 		if (!isGenesisBlockValid()) | ||||
| 			rebuildBlockchain(); | ||||
|  | ||||
| 		boolean isTopOnly = Settings.getInstance().isTopOnly(); | ||||
| 		boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); | ||||
| 		boolean canBootstrap = Settings.getInstance().getBootstrap(); | ||||
| 		boolean needsArchiveRebuild = false; | ||||
| 		BlockData chainTip; | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			chainTip = repository.getBlockRepository().getLastBlock(); | ||||
|  | ||||
| 			// Ensure archive is (at least partially) intact, and force a bootstrap if it isn't | ||||
| 			if (!isTopOnly && archiveEnabled && canBootstrap) { | ||||
| 				needsArchiveRebuild = (repository.getBlockArchiveRepository().fromHeight(2) == null); | ||||
| 				if (needsArchiveRebuild) { | ||||
| 					LOGGER.info("Couldn't retrieve block 2 from archive. Bootstrapping..."); | ||||
|  | ||||
| 					// If there are minting accounts, make sure to back them up | ||||
| 					// Don't backup if there are no minting accounts, as this can cause problems | ||||
| 					if (!repository.getAccountRepository().getMintingAccounts().isEmpty()) { | ||||
| 						Controller.getInstance().exportRepositoryData(); | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1); | ||||
|  | ||||
| 		if (isTopOnly && hasBlocks) { | ||||
| 			// Top-only mode is enabled and we have blocks, so it's possible that the genesis block has been pruned | ||||
| 			// It's best not to validate it, and there's no real need to | ||||
| 		} else { | ||||
| 			// Check first block is Genesis Block | ||||
| 			if (!isGenesisBlockValid() || needsArchiveRebuild) { | ||||
| 				try { | ||||
| 					rebuildBlockchain(); | ||||
|  | ||||
| 				} catch (InterruptedException e) { | ||||
| 					throw new DataException(String.format("Interrupted when trying to rebuild blockchain: %s", e.getMessage())); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// We need to create a new connection, as the previous repository and its connections may be been | ||||
| 		// closed by rebuildBlockchain() if a bootstrap was applied | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			repository.checkConsistency(); | ||||
|  | ||||
| 			int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - 1440, 1); | ||||
| 			// Set the number of blocks to validate based on the pruned state of the chain | ||||
| 			// If pruned, subtract an extra 10 to allow room for error | ||||
| 			int blocksToValidate = (isTopOnly || archiveEnabled) ? Settings.getInstance().getPruneBlockLimit() - 10 : 1440; | ||||
|  | ||||
| 			int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - blocksToValidate, 1); | ||||
| 			BlockData detachedBlockData = repository.getBlockRepository().getDetachedBlockSignature(startHeight); | ||||
|  | ||||
| 			if (detachedBlockData != null) { | ||||
| 				LOGGER.error(String.format("Block %d's reference does not match any block's signature", detachedBlockData.getHeight())); | ||||
| 				LOGGER.error(String.format("Block %d's reference does not match any block's signature", | ||||
| 						detachedBlockData.getHeight())); | ||||
| 				LOGGER.error(String.format("Your chain may be invalid and you should consider bootstrapping" + | ||||
| 						" or re-syncing from genesis.")); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 				// Wait for blockchain lock (whereas orphan() only tries to get lock) | ||||
| 				ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); | ||||
| 				blockchainLock.lock(); | ||||
| 				try { | ||||
| 					LOGGER.info(String.format("Orphaning back to block %d", detachedBlockData.getHeight() - 1)); | ||||
| 					orphan(detachedBlockData.getHeight() - 1); | ||||
| 				} finally { | ||||
| 					blockchainLock.unlock(); | ||||
| 	/** | ||||
| 	 * More thorough blockchain validation method. Useful for validating bootstraps. | ||||
| 	 * A DataException is thrown if anything is invalid. | ||||
| 	 * | ||||
| 	 * @throws DataException | ||||
| 	 */ | ||||
| 	public static void validateAllBlocks() throws DataException { | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			BlockData chainTip = repository.getBlockRepository().getLastBlock(); | ||||
| 			final int chainTipHeight = chainTip.getHeight(); | ||||
| 			final int oldestBlock = 1; // TODO: increase if in pruning mode | ||||
| 			byte[] lastReference = null; | ||||
|  | ||||
| 			for (int height = chainTipHeight; height > oldestBlock; height--) { | ||||
| 				BlockData blockData = repository.getBlockRepository().fromHeight(height); | ||||
| 				if (blockData == null) { | ||||
| 					blockData = repository.getBlockArchiveRepository().fromHeight(height); | ||||
| 				} | ||||
|  | ||||
| 				if (blockData == null) { | ||||
| 					String error = String.format("Missing block at height %d", height); | ||||
| 					LOGGER.error(error); | ||||
| 					throw new DataException(error); | ||||
| 				} | ||||
|  | ||||
| 				if (height != chainTipHeight) { | ||||
| 					// Check reference | ||||
| 					if (!Arrays.equals(blockData.getSignature(), lastReference)) { | ||||
| 						String error = String.format("Invalid reference for block at height %d: %s (should be %s)", | ||||
| 								height, Base58.encode(blockData.getReference()), Base58.encode(lastReference)); | ||||
| 						LOGGER.error(error); | ||||
| 						throw new DataException(error); | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				lastReference = blockData.getReference(); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| @@ -551,7 +632,15 @@ public class BlockChain { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private static void rebuildBlockchain() throws DataException { | ||||
| 	private static void rebuildBlockchain() throws DataException, InterruptedException { | ||||
| 		boolean shouldBootstrap = Settings.getInstance().getBootstrap(); | ||||
| 		if (shouldBootstrap) { | ||||
| 			// Settings indicate that we should apply a bootstrap rather than rebuilding and syncing from genesis | ||||
| 			Bootstrap bootstrap = new Bootstrap(); | ||||
| 			bootstrap.startImport(); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		// (Re)build repository | ||||
| 		if (!RepositoryManager.wasPristineAtOpen()) | ||||
| 			RepositoryManager.rebuild(); | ||||
|   | ||||
| @@ -0,0 +1,114 @@ | ||||
| package org.qortal.block; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.naming.Name; | ||||
| import org.qortal.repository.DataException; | ||||
|  | ||||
| import java.util.HashMap; | ||||
| import java.util.Map; | ||||
|  | ||||
| /** | ||||
|  * Invalid Name Registration Blocks | ||||
|  * <p> | ||||
|  * A node minted a version of block 535658 that contained one transaction: | ||||
|  * a REGISTER_NAME transaction that attempted to register a name that was already registered. | ||||
|  * <p> | ||||
|  * This invalid transaction made block 535658 (rightly) invalid to several nodes, | ||||
|  * which refused to use that block. | ||||
|  * However, it seems there were no other nodes minting an alternative, valid block at that time | ||||
|  * and so the chain stalled for several nodes in the network. | ||||
|  * <p> | ||||
|  * Additionally, the invalid block 535658 affected all new installations, regardless of whether | ||||
|  * they synchronized from scratch (block 1) or used an 'official release' bootstrap. | ||||
|  * <p> | ||||
|  * The diagnosis found the following: | ||||
|  * - The original problem occurred in block 535205 where for some unknown reason many nodes didn't | ||||
|  *   add the name from a REGISTER_NAME transaction to their Names table. | ||||
|  * - As a result, those nodes had a corrupt db, because they weren't holding a record of the name. | ||||
|  * - This invalid db then caused them to treat a candidate for block 535658 as valid when it | ||||
|  *   should have been invalid. | ||||
|  * - As such, the chain continued on with a technically invalid block in it, for a subset of the network | ||||
|  * <p> | ||||
|  * As with block 212937, there were three options, but the only feasible one was to apply edits to block | ||||
|  * 535658 to make it valid. There were several cross-chain trades completed after this block, so doing | ||||
|  * any kind of rollback was out of the question. | ||||
|  * <p> | ||||
|  * To complicate things further, a custom data field was used for the first REGISTER_NAME transaction, | ||||
|  * and the default data field was used for the second. So it was important that all nodes ended up with | ||||
|  * the exact same data regardless of how they arrived there. | ||||
|  * <p> | ||||
|  * The invalid block 535658 signature is: <tt>3oiuDhok...NdXvCLEV</tt>. | ||||
|  * <p> | ||||
|  * The invalid transaction in block 212937 is: | ||||
|  * <p> | ||||
|  * <code><pre> | ||||
| 	 { | ||||
| 		 "type": "REGISTER_NAME", | ||||
| 		 "timestamp": 1630739437517, | ||||
| 		 "reference": "4peRechwSPxP6UkRj9Y8ox9YxkWb34sWk5zyMc1WyMxEsACxD4Gmm7LZVsQ6Skpze8QCSBMZasvEZg6RgdqkyADW", | ||||
| 		 "fee": "0.00100000", | ||||
| 		 "signature": "2t1CryCog8KPDBarzY5fDCKu499nfnUcGrz4Lz4w5wNb5nWqm7y126P48dChYY7huhufcBV3RJPkgKP4Ywxc1gXx", | ||||
| 		 "txGroupId": 0, | ||||
| 		 "blockHeight": 535658, | ||||
| 		 "approvalStatus": "NOT_REQUIRED", | ||||
| 		 "creatorAddress": "Qbx9ojxv7XNi1xDMWzzw7xDvd1zYW6SKFB", | ||||
| 		 "registrantPublicKey": "HJqGEf6cW695Xun4ydhkB2excGFwsDxznhNCRHZStyyx", | ||||
| 		 "name": "Qplay", | ||||
| 		 "data": "Registered Name on the Qortal Chain" | ||||
| 	 } | ||||
|    </pre></code> | ||||
|  * <p> | ||||
|  * Account <tt>Qbx9ojxv7XNi1xDMWzzw7xDvd1zYW6SKFB</tt> attempted to register the name <tt>Qplay</tt> | ||||
|  * when they had already registered it 12 hours before in block <tt>535205</tt>. | ||||
|  * <p> | ||||
|  * However, on the broken DB nodes, their Names table was missing a record for the `Qplay` name | ||||
|  * which was sufficient to make the transaction valid. | ||||
|  * | ||||
|  * This problem then occurred two more times, in blocks 536140 and 541334 | ||||
|  * To reduce duplication, I have combined all three block fixes into a single class | ||||
|  * | ||||
|  */ | ||||
| public final class InvalidNameRegistrationBlocks { | ||||
|  | ||||
| 	private static final Logger LOGGER = LogManager.getLogger(InvalidNameRegistrationBlocks.class); | ||||
|  | ||||
| 	public static Map<Integer, String> invalidBlocksNamesMap = new HashMap<Integer, String>() | ||||
| 	{ | ||||
| 		{ | ||||
| 			put(535658, "Qplay"); | ||||
| 			put(536140, "Qweb"); | ||||
| 			put(541334, "Qithub"); | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	private InvalidNameRegistrationBlocks() { | ||||
| 		/* Do not instantiate */ | ||||
| 	} | ||||
|  | ||||
| 	public static boolean isAffectedBlock(int height) { | ||||
| 		return (invalidBlocksNamesMap.containsKey(height)); | ||||
| 	} | ||||
|  | ||||
| 	public static void processFix(Block block) throws DataException { | ||||
| 		Integer blockHeight = block.getBlockData().getHeight(); | ||||
| 		String invalidName = invalidBlocksNamesMap.get(blockHeight); | ||||
| 		if (invalidName == null) { | ||||
| 			throw new DataException(String.format("Unable to lookup invalid name for block height %d", blockHeight)); | ||||
| 		} | ||||
|  | ||||
| 		// Unregister the existing name record if it exists | ||||
| 		// This ensures that the duplicate name is considered valid, and therefore | ||||
| 		// the second (i.e. duplicate) REGISTER_NAME transaction data is applied. | ||||
| 		// Both were issued by the same user account, so there is no conflict. | ||||
| 		Name name = new Name(block.repository, invalidName); | ||||
| 		name.unregister(); | ||||
|  | ||||
| 		LOGGER.debug("Applied name registration patch for block {}", blockHeight); | ||||
| 	} | ||||
|  | ||||
| 	// Note: | ||||
| 	// There is no need to write an orphanFix() method, as we do not have | ||||
| 	// the necessary ATStatesData to orphan back this far anyway | ||||
|  | ||||
| } | ||||
| @@ -1,91 +0,0 @@ | ||||
| package org.qortal.controller; | ||||
|  | ||||
| import java.util.Arrays; | ||||
| import java.util.List; | ||||
| import java.util.Random; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; | ||||
| import org.qortal.data.transaction.ArbitraryTransactionData; | ||||
| import org.qortal.data.transaction.TransactionData; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.RepositoryManager; | ||||
| import org.qortal.transaction.ArbitraryTransaction; | ||||
| import org.qortal.transaction.Transaction.TransactionType; | ||||
|  | ||||
| public class ArbitraryDataManager extends Thread { | ||||
|  | ||||
| 	private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataManager.class); | ||||
| 	private static final List<TransactionType> ARBITRARY_TX_TYPE = Arrays.asList(TransactionType.ARBITRARY); | ||||
|  | ||||
| 	private static ArbitraryDataManager instance; | ||||
|  | ||||
| 	private volatile boolean isStopping = false; | ||||
|  | ||||
| 	private ArbitraryDataManager() { | ||||
| 	} | ||||
|  | ||||
| 	public static ArbitraryDataManager getInstance() { | ||||
| 		if (instance == null) | ||||
| 			instance = new ArbitraryDataManager(); | ||||
|  | ||||
| 		return instance; | ||||
| 	} | ||||
|  | ||||
| 	@Override | ||||
| 	public void run() { | ||||
| 		Thread.currentThread().setName("Arbitrary Data Manager"); | ||||
|  | ||||
| 		try { | ||||
| 			while (!isStopping) { | ||||
| 				Thread.sleep(2000); | ||||
|  | ||||
| 				// Any arbitrary transactions we want to fetch data for? | ||||
| 				try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 					List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, ARBITRARY_TX_TYPE, null, null, ConfirmationStatus.BOTH, null, null, true); | ||||
| 					if (signatures == null || signatures.isEmpty()) | ||||
| 						continue; | ||||
|  | ||||
| 					// Filter out those that already have local data | ||||
| 					signatures.removeIf(signature -> hasLocalData(repository, signature)); | ||||
|  | ||||
| 					if (signatures.isEmpty()) | ||||
| 						continue; | ||||
|  | ||||
| 					// Pick one at random | ||||
| 					final int index = new Random().nextInt(signatures.size()); | ||||
| 					byte[] signature = signatures.get(index); | ||||
|  | ||||
| 					Controller.getInstance().fetchArbitraryData(signature); | ||||
| 				} catch (DataException e) { | ||||
| 					LOGGER.error("Repository issue when fetching arbitrary transaction data", e); | ||||
| 				} | ||||
| 			} | ||||
| 		} catch (InterruptedException e) { | ||||
| 			// Fall-through to exit thread... | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public void shutdown() { | ||||
| 		isStopping = true; | ||||
| 		this.interrupt(); | ||||
| 	} | ||||
|  | ||||
| 	private boolean hasLocalData(final Repository repository, final byte[] signature) { | ||||
| 		try { | ||||
| 			TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); | ||||
| 			if (!(transactionData instanceof ArbitraryTransactionData)) | ||||
| 				return true; | ||||
|  | ||||
| 			ArbitraryTransaction arbitraryTransaction = new ArbitraryTransaction(repository, transactionData); | ||||
|  | ||||
| 			return arbitraryTransaction.isDataLocal(); | ||||
| 		} catch (DataException e) { | ||||
| 			LOGGER.error("Repository issue when checking arbitrary transaction's data is local", e); | ||||
| 			return true; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @@ -14,6 +14,7 @@ import java.security.NoSuchAlgorithmException; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.List; | ||||
| import java.util.concurrent.TimeoutException; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| @@ -215,8 +216,17 @@ public class AutoUpdate extends Thread { | ||||
| 		} | ||||
|  | ||||
| 		// Give repository a chance to backup in case things go badly wrong (if enabled) | ||||
| 		if (Settings.getInstance().getRepositoryBackupInterval() > 0) | ||||
| 			RepositoryManager.backup(true); | ||||
| 		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 auto update anyway... | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Call ApplyUpdate to end this process (unlocking current JAR so it can be replaced) | ||||
| 		String javaHome = System.getProperty("java.home"); | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| package org.qortal.controller; | ||||
|  | ||||
| import java.math.BigInteger; | ||||
| import java.text.DecimalFormat; | ||||
| import java.text.NumberFormat; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.Iterator; | ||||
| @@ -20,6 +22,7 @@ import org.qortal.data.account.MintingAccountData; | ||||
| import org.qortal.data.account.RewardShareData; | ||||
| import org.qortal.data.block.BlockData; | ||||
| import org.qortal.data.block.BlockSummaryData; | ||||
| import org.qortal.data.block.CommonBlockData; | ||||
| import org.qortal.data.transaction.TransactionData; | ||||
| import org.qortal.network.Network; | ||||
| import org.qortal.network.Peer; | ||||
| @@ -44,6 +47,9 @@ public class BlockMinter extends Thread { | ||||
| 	private static Long lastLogTimestamp; | ||||
| 	private static Long logTimeout; | ||||
|  | ||||
| 	// Recovery | ||||
| 	public static final long INVALID_BLOCK_RECOVERY_TIMEOUT = 10 * 60 * 1000L; // ms | ||||
|  | ||||
| 	// Constructors | ||||
|  | ||||
| 	public BlockMinter() { | ||||
| @@ -72,6 +78,10 @@ public class BlockMinter extends Thread { | ||||
| 			BlockRepository blockRepository = repository.getBlockRepository(); | ||||
| 			BlockData previousBlockData = null; | ||||
|  | ||||
| 			// Vars to keep track of blocks that were skipped due to chain weight | ||||
| 			byte[] parentSignatureForLastLowWeightBlock = null; | ||||
| 			Long timeOfLastLowWeightBlock = null; | ||||
|  | ||||
| 			List<Block> newBlocks = new ArrayList<>(); | ||||
|  | ||||
| 			// Flags for tracking change in whether minting is possible, | ||||
| @@ -127,6 +137,15 @@ public class BlockMinter extends Thread { | ||||
| 						madi.remove(); | ||||
| 						continue; | ||||
| 					} | ||||
|  | ||||
| 					// Optional (non-validated) prevention of block submissions below a defined level. | ||||
| 					// This is an unvalidated version of Blockchain.minAccountLevelToMint | ||||
| 					// and exists only to reduce block candidates by default. | ||||
| 					int level = mintingAccount.getEffectiveMintingLevel(); | ||||
| 					if (level < BlockChain.getInstance().getMinAccountLevelForBlockSubmissions()) { | ||||
| 						madi.remove(); | ||||
| 						continue; | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				List<Peer> peers = Network.getInstance().getHandshakedPeers(); | ||||
| @@ -137,16 +156,32 @@ public class BlockMinter extends Thread { | ||||
|  | ||||
| 				// Disregard peers that don't have a recent block, but only if we're not in recovery mode. | ||||
| 				// In that mode, we want to allow minting on top of older blocks, to recover stalled networks. | ||||
| 				if (Controller.getInstance().getRecoveryMode() == false) | ||||
| 				if (Synchronizer.getInstance().getRecoveryMode() == false) | ||||
| 					peers.removeIf(Controller.hasNoRecentBlock); | ||||
|  | ||||
| 				// Don't mint if we don't have enough up-to-date peers as where would the transactions/consensus come from? | ||||
| 				if (peers.size() < Settings.getInstance().getMinBlockchainPeers()) | ||||
| 					continue; | ||||
|  | ||||
| 				// If we are stuck on an invalid block, we should allow an alternative to be minted | ||||
| 				boolean recoverInvalidBlock = false; | ||||
| 				if (Synchronizer.getInstance().timeInvalidBlockLastReceived != null) { | ||||
| 					// We've had at least one invalid block | ||||
| 					long timeSinceLastValidBlock = NTP.getTime() - Synchronizer.getInstance().timeValidBlockLastReceived; | ||||
| 					long timeSinceLastInvalidBlock = NTP.getTime() - Synchronizer.getInstance().timeInvalidBlockLastReceived; | ||||
| 					if (timeSinceLastValidBlock > INVALID_BLOCK_RECOVERY_TIMEOUT) { | ||||
| 						if (timeSinceLastInvalidBlock < INVALID_BLOCK_RECOVERY_TIMEOUT) { | ||||
| 							// Last valid block was more than 10 mins ago, but we've had an invalid block since then | ||||
| 							// Assume that the chain has stalled because there is no alternative valid candidate | ||||
| 							// Enter recovery mode to allow alternative, valid candidates to be minted | ||||
| 							recoverInvalidBlock = true; | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				// If our latest block isn't recent then we need to synchronize instead of minting, unless we're in recovery mode. | ||||
| 				if (!peers.isEmpty() && lastBlockData.getTimestamp() < minLatestBlockTimestamp) | ||||
| 					if (Controller.getInstance().getRecoveryMode() == false) | ||||
| 					if (Synchronizer.getInstance().getRecoveryMode() == false && recoverInvalidBlock == false) | ||||
| 						continue; | ||||
|  | ||||
| 				// There are enough peers with a recent block and our latest block is recent | ||||
| @@ -160,6 +195,9 @@ public class BlockMinter extends Thread { | ||||
|  | ||||
| 					// Reduce log timeout | ||||
| 					logTimeout = 10 * 1000L; | ||||
|  | ||||
| 					// Last low weight block is no longer valid | ||||
| 					parentSignatureForLastLowWeightBlock = null; | ||||
| 				} | ||||
|  | ||||
| 				// Discard accounts we have already built blocks with | ||||
| @@ -176,6 +214,14 @@ public class BlockMinter extends Thread { | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				if (parentSignatureForLastLowWeightBlock != null) { | ||||
| 					// The last iteration found a higher weight block in the network, so sleep for a while | ||||
| 					// to allow is to sync the higher weight chain. We are sleeping here rather than when | ||||
| 					// detected as we don't want to hold the blockchain lock open. | ||||
| 					LOGGER.info("Sleeping for 10 seconds..."); | ||||
| 					Thread.sleep(10 * 1000L); | ||||
| 				} | ||||
|  | ||||
| 				for (PrivateKeyAccount mintingAccount : newBlocksMintingAccounts) { | ||||
| 					// First block does the AT heavy-lifting | ||||
| 					if (newBlocks.isEmpty()) { | ||||
| @@ -230,6 +276,8 @@ public class BlockMinter extends Thread { | ||||
| 						if (testBlock.isTimestampValid() != ValidationResult.OK) | ||||
| 							continue; | ||||
|  | ||||
| 						testBlock.preProcess(); | ||||
|  | ||||
| 						// Is new block valid yet? (Before adding unconfirmed transactions) | ||||
| 						ValidationResult result = testBlock.isValid(); | ||||
| 						if (result != ValidationResult.OK) { | ||||
| @@ -265,6 +313,41 @@ public class BlockMinter extends Thread { | ||||
| 						} | ||||
| 					} | ||||
|  | ||||
| 					try { | ||||
| 						if (this.higherWeightChainExists(repository, bestWeight)) { | ||||
|  | ||||
| 							// Check if the base block has updated since the last time we were here | ||||
| 							if (parentSignatureForLastLowWeightBlock == null || timeOfLastLowWeightBlock == null || | ||||
| 									!Arrays.equals(parentSignatureForLastLowWeightBlock, previousBlockData.getSignature())) { | ||||
| 								// We've switched to a different chain, so reset the timer | ||||
| 								timeOfLastLowWeightBlock = NTP.getTime(); | ||||
| 							} | ||||
| 							parentSignatureForLastLowWeightBlock = previousBlockData.getSignature(); | ||||
|  | ||||
| 							// If less than 30 seconds has passed since first detection the higher weight chain, | ||||
| 							// we should skip our block submission to give us the opportunity to sync to the better chain | ||||
| 							if (NTP.getTime() - timeOfLastLowWeightBlock < 30*1000L) { | ||||
| 								LOGGER.info("Higher weight chain found in peers, so not signing a block this round"); | ||||
| 								LOGGER.info("Time since detected: {}", NTP.getTime() - timeOfLastLowWeightBlock); | ||||
| 								continue; | ||||
| 							} | ||||
| 							else { | ||||
| 								// More than 30 seconds have passed, so we should submit our block candidate anyway. | ||||
| 								LOGGER.info("More than 30 seconds passed, so proceeding to submit block candidate..."); | ||||
| 							} | ||||
| 						} | ||||
| 						else { | ||||
| 							LOGGER.debug("No higher weight chain found in peers"); | ||||
| 						} | ||||
| 					} catch (DataException e) { | ||||
| 						LOGGER.debug("Unable to check for a higher weight chain. Proceeding anyway..."); | ||||
| 					} | ||||
|  | ||||
| 					// Clear variables that track low weight blocks | ||||
| 					parentSignatureForLastLowWeightBlock = null; | ||||
| 					timeOfLastLowWeightBlock = null; | ||||
|  | ||||
|  | ||||
| 					// Add unconfirmed transactions | ||||
| 					addUnconfirmedTransactions(repository, newBlock); | ||||
|  | ||||
| @@ -421,7 +504,8 @@ public class BlockMinter extends Thread { | ||||
|  | ||||
| 			// Add to blockchain | ||||
| 			newBlock.process(); | ||||
| 			LOGGER.info(String.format("Minted new test block: %d", newBlock.getBlockData().getHeight())); | ||||
| 			LOGGER.info(String.format("Minted new test block: %d sig: %.8s", | ||||
| 					newBlock.getBlockData().getHeight(), Base58.encode(newBlock.getBlockData().getSignature()))); | ||||
|  | ||||
| 			repository.saveChanges(); | ||||
|  | ||||
| @@ -431,6 +515,61 @@ public class BlockMinter extends Thread { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private BigInteger getOurChainWeightSinceBlock(Repository repository, BlockSummaryData commonBlock, List<BlockSummaryData> peerBlockSummaries) throws DataException { | ||||
| 		final int commonBlockHeight = commonBlock.getHeight(); | ||||
| 		final byte[] commonBlockSig = commonBlock.getSignature(); | ||||
| 		int mutualHeight = commonBlockHeight; | ||||
|  | ||||
| 		// Fetch our corresponding block summaries | ||||
| 		final BlockData ourLatestBlockData = repository.getBlockRepository().getLastBlock(); | ||||
| 		List<BlockSummaryData> ourBlockSummaries = repository.getBlockRepository() | ||||
| 				.getBlockSummaries(commonBlockHeight + 1, ourLatestBlockData.getHeight()); | ||||
| 		if (!ourBlockSummaries.isEmpty()) { | ||||
| 			Synchronizer.getInstance().populateBlockSummariesMinterLevels(repository, ourBlockSummaries); | ||||
| 		} | ||||
|  | ||||
| 		if (ourBlockSummaries != null && peerBlockSummaries != null) { | ||||
| 			mutualHeight += Math.min(ourBlockSummaries.size(), peerBlockSummaries.size()); | ||||
| 		} | ||||
| 		return Block.calcChainWeight(commonBlockHeight, commonBlockSig, ourBlockSummaries, mutualHeight); | ||||
| 	} | ||||
|  | ||||
| 	private boolean higherWeightChainExists(Repository repository, BigInteger blockCandidateWeight) throws DataException { | ||||
| 		if (blockCandidateWeight == null) { | ||||
| 			// Can't make decisions without knowing the block candidate weight | ||||
| 			return false; | ||||
| 		} | ||||
| 		NumberFormat formatter = new DecimalFormat("0.###E0"); | ||||
|  | ||||
| 		List<Peer> peers = Network.getInstance().getHandshakedPeers(); | ||||
| 		// Loop through handshaked peers and check for any new block candidates | ||||
| 		for (Peer peer : peers) { | ||||
| 			if (peer.getCommonBlockData() != null && peer.getCommonBlockData().getCommonBlockSummary() != null) { | ||||
| 				// This peer has common block data | ||||
| 				CommonBlockData commonBlockData = peer.getCommonBlockData(); | ||||
| 				BlockSummaryData commonBlockSummaryData = commonBlockData.getCommonBlockSummary(); | ||||
| 				if (commonBlockData.getChainWeight() != null) { | ||||
| 					// The synchronizer has calculated this peer's chain weight | ||||
| 					BigInteger ourChainWeightSinceCommonBlock = this.getOurChainWeightSinceBlock(repository, commonBlockSummaryData, commonBlockData.getBlockSummariesAfterCommonBlock()); | ||||
| 					BigInteger ourChainWeight = ourChainWeightSinceCommonBlock.add(blockCandidateWeight); | ||||
| 					BigInteger peerChainWeight = commonBlockData.getChainWeight(); | ||||
| 					if (peerChainWeight.compareTo(ourChainWeight) >= 0) { | ||||
| 						// This peer has a higher weight chain than ours | ||||
| 						LOGGER.debug("Peer {} is on a higher weight chain ({}) than ours ({})", peer, formatter.format(peerChainWeight), formatter.format(ourChainWeight)); | ||||
| 						return true; | ||||
|  | ||||
| 					} else { | ||||
| 						LOGGER.debug("Peer {} is on a lower weight chain ({}) than ours ({})", peer, formatter.format(peerChainWeight), formatter.format(ourChainWeight)); | ||||
| 					} | ||||
| 				} else { | ||||
| 					LOGGER.debug("Peer {} has no chain weight", peer); | ||||
| 				} | ||||
| 			} else { | ||||
| 				LOGGER.debug("Peer {} has no common block data", peer); | ||||
| 			} | ||||
| 		} | ||||
| 		return false; | ||||
| 	} | ||||
| 	private static void moderatedLog(Runnable logFunction) { | ||||
| 		// We only log if logging at TRACE or previous log timeout has expired | ||||
| 		if (!LOGGER.isTraceEnabled() && lastLogTimestamp != null && lastLogTimestamp + logTimeout > System.currentTimeMillis()) | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,14 +1,13 @@ | ||||
| package org.qortal.controller; | ||||
|  | ||||
| import java.math.BigInteger; | ||||
| import java.security.SecureRandom; | ||||
| import java.text.DecimalFormat; | ||||
| import java.text.NumberFormat; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.List; | ||||
| import java.util.*; | ||||
| import java.util.concurrent.TimeUnit; | ||||
| import java.util.concurrent.locks.ReentrantLock; | ||||
| import java.util.stream.Collectors; | ||||
| import java.util.Iterator; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| @@ -23,6 +22,7 @@ import org.qortal.data.block.CommonBlockData; | ||||
| import org.qortal.data.network.PeerChainTipData; | ||||
| import org.qortal.data.transaction.RewardShareTransactionData; | ||||
| import org.qortal.data.transaction.TransactionData; | ||||
| import org.qortal.network.Network; | ||||
| import org.qortal.network.Peer; | ||||
| import org.qortal.network.message.BlockMessage; | ||||
| import org.qortal.network.message.BlockSummariesMessage; | ||||
| @@ -35,16 +35,18 @@ import org.qortal.network.message.Message.MessageType; | ||||
| 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.utils.Base58; | ||||
| import org.qortal.utils.ByteArray; | ||||
| import org.qortal.utils.NTP; | ||||
|  | ||||
| public class Synchronizer { | ||||
| public class Synchronizer extends Thread { | ||||
|  | ||||
| 	private static final Logger LOGGER = LogManager.getLogger(Synchronizer.class); | ||||
|  | ||||
| 	/** Max number of new blocks we aim to add to chain tip in each sync round */ | ||||
| 	private static final int SYNC_BATCH_SIZE = 200; // XXX move to Settings? | ||||
| 	private static final int SYNC_BATCH_SIZE = 1000; // XXX move to Settings? | ||||
|  | ||||
| 	/** Initial jump back of block height when searching for common block with peer */ | ||||
| 	private static final int INITIAL_BLOCK_STEP = 8; | ||||
| @@ -57,10 +59,36 @@ public class Synchronizer { | ||||
| 	/** Maximum number of block signatures we ask from peer in one go */ | ||||
| 	private static final int MAXIMUM_REQUEST_SIZE = 200; // XXX move to Settings? | ||||
|  | ||||
| 	/** Number of retry attempts if a peer fails to respond with the requested data */ | ||||
| 	private static final int MAXIMUM_RETRIES = 2; // XXX move to Settings? | ||||
| 	private static final long RECOVERY_MODE_TIMEOUT = 10 * 60 * 1000L; // ms | ||||
|  | ||||
|  | ||||
| 	private boolean running; | ||||
|  | ||||
| 	/** Latest block signatures from other peers that we know are on inferior chains. */ | ||||
| 	List<ByteArray> inferiorChainSignatures = new ArrayList<>(); | ||||
|  | ||||
| 	/** Recovery mode, which is used to bring back a stalled network */ | ||||
| 	private boolean recoveryMode = false; | ||||
| 	private boolean peersAvailable = true; // peersAvailable must default to true | ||||
| 	private long timePeersLastAvailable = 0; | ||||
|  | ||||
| 	// Keep track of the size of the last re-org, so it can be logged | ||||
| 	private int lastReorgSize; | ||||
|  | ||||
| 	/** Synchronization object for sync variables below */ | ||||
| 	public final Object syncLock = new Object(); | ||||
| 	/** Whether we are attempting to synchronize. */ | ||||
| 	private volatile boolean isSynchronizing = false; | ||||
| 	/** Temporary estimate of synchronization progress for SysTray use. */ | ||||
| 	private volatile int syncPercent = 0; | ||||
|  | ||||
| 	private static volatile boolean requestSync = false; | ||||
|  | ||||
| 	// Keep track of invalid blocks so that we don't keep trying to sync them | ||||
| 	private Map<String, Long> invalidBlockSignatures = Collections.synchronizedMap(new HashMap<>()); | ||||
| 	public Long timeValidBlockLastReceived = null; | ||||
| 	public Long timeInvalidBlockLastReceived = null; | ||||
|  | ||||
| 	private static Synchronizer instance; | ||||
|  | ||||
| 	public enum SynchronizationResult { | ||||
| @@ -70,6 +98,7 @@ public class Synchronizer { | ||||
| 	// Constructors | ||||
|  | ||||
| 	private Synchronizer() { | ||||
| 		this.running = true; | ||||
| 	} | ||||
|  | ||||
| 	public static Synchronizer getInstance() { | ||||
| @@ -80,6 +109,276 @@ public class Synchronizer { | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	@Override | ||||
| 	public void run() { | ||||
| 		try { | ||||
| 			while (running && !Controller.isStopping()) { | ||||
| 				Thread.sleep(1000); | ||||
|  | ||||
| 				if (requestSync) { | ||||
| 					requestSync = false; | ||||
| 					boolean success = Synchronizer.getInstance().potentiallySynchronize(); | ||||
| 					if (!success) { | ||||
| 						// Something went wrong, so try again next time | ||||
| 						requestSync = true; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} catch (InterruptedException e) { | ||||
| 			// Clear interrupted flag so we can shutdown trim threads | ||||
| 			Thread.interrupted(); | ||||
| 			// Fall-through to exit | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public void shutdown() { | ||||
| 		this.running = false; | ||||
| 		this.interrupt(); | ||||
| 	} | ||||
|  | ||||
|  | ||||
|  | ||||
| 	public boolean isSynchronizing() { | ||||
| 		return this.isSynchronizing; | ||||
| 	} | ||||
|  | ||||
| 	public Integer getSyncPercent() { | ||||
| 		synchronized (this.syncLock) { | ||||
| 			return this.isSynchronizing ? this.syncPercent : null; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public void requestSync() { | ||||
| 		requestSync = true; | ||||
| 	} | ||||
|  | ||||
| 	public boolean isSyncRequested() { | ||||
| 		return requestSync; | ||||
| 	} | ||||
|  | ||||
| 	public boolean getRecoveryMode() { | ||||
| 		return this.recoveryMode; | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	public boolean potentiallySynchronize() throws InterruptedException { | ||||
| 		// Already synchronizing via another thread? | ||||
| 		if (this.isSynchronizing) | ||||
| 			return true; | ||||
|  | ||||
| 		List<Peer> peers = Network.getInstance().getHandshakedPeers(); | ||||
|  | ||||
| 		// Disregard peers that have "misbehaved" recently | ||||
| 		peers.removeIf(Controller.hasMisbehaved); | ||||
|  | ||||
| 		// Disregard peers that only have genesis block | ||||
| 		peers.removeIf(Controller.hasOnlyGenesisBlock); | ||||
|  | ||||
| 		// Disregard peers that don't have a recent block | ||||
| 		peers.removeIf(Controller.hasNoRecentBlock); | ||||
|  | ||||
| 		// Disregard peers that are on an old version | ||||
| 		peers.removeIf(Controller.hasOldVersion); | ||||
|  | ||||
| 		checkRecoveryModeForPeers(peers); | ||||
| 		if (recoveryMode) { | ||||
| 			peers = Network.getInstance().getHandshakedPeers(); | ||||
| 			peers.removeIf(Controller.hasOnlyGenesisBlock); | ||||
| 			peers.removeIf(Controller.hasMisbehaved); | ||||
| 			peers.removeIf(Controller.hasOldVersion); | ||||
| 		} | ||||
|  | ||||
| 		// Check we have enough peers to potentially synchronize | ||||
| 		if (peers.size() < Settings.getInstance().getMinBlockchainPeers()) | ||||
| 			return true; | ||||
|  | ||||
| 		// Disregard peers that have no block signature or the same block signature as us | ||||
| 		peers.removeIf(Controller.hasNoOrSameBlock); | ||||
|  | ||||
| 		// Disregard peers that are on the same block as last sync attempt and we didn't like their chain | ||||
| 		peers.removeIf(Controller.hasInferiorChainTip); | ||||
|  | ||||
| 		final int peersBeforeComparison = peers.size(); | ||||
|  | ||||
| 		// Request recent block summaries from the remaining peers, and locate our common block with each | ||||
| 		Synchronizer.getInstance().findCommonBlocksWithPeers(peers); | ||||
|  | ||||
| 		// Compare the peers against each other, and against our chain, which will return an updated list excluding those without common blocks | ||||
| 		peers = Synchronizer.getInstance().comparePeers(peers); | ||||
|  | ||||
| 		// We may have added more inferior chain tips when comparing peers, so remove any peers that are currently on those chains | ||||
| 		peers.removeIf(Controller.hasInferiorChainTip); | ||||
|  | ||||
| 		final int peersRemoved = peersBeforeComparison - peers.size(); | ||||
| 		if (peersRemoved > 0 && peers.size() > 0) | ||||
| 			LOGGER.debug(String.format("Ignoring %d peers on inferior chains. Peers remaining: %d", peersRemoved, peers.size())); | ||||
|  | ||||
| 		if (peers.isEmpty()) | ||||
| 			return true; | ||||
|  | ||||
| 		if (peers.size() > 1) { | ||||
| 			StringBuilder finalPeersString = new StringBuilder(); | ||||
| 			for (Peer peer : peers) | ||||
| 				finalPeersString = finalPeersString.length() > 0 ? finalPeersString.append(", ").append(peer) : finalPeersString.append(peer); | ||||
| 			LOGGER.debug(String.format("Choosing random peer from: [%s]", finalPeersString.toString())); | ||||
| 		} | ||||
|  | ||||
| 		// Pick random peer to sync with | ||||
| 		int index = new SecureRandom().nextInt(peers.size()); | ||||
| 		Peer peer = peers.get(index); | ||||
|  | ||||
| 		SynchronizationResult syncResult = actuallySynchronize(peer, false); | ||||
| 		if (syncResult == SynchronizationResult.NO_BLOCKCHAIN_LOCK) { | ||||
| 			// No blockchain lock - force a retry by returning false | ||||
| 			return false; | ||||
| 		} | ||||
|  | ||||
| 		return true; | ||||
| 	} | ||||
|  | ||||
| 	public SynchronizationResult actuallySynchronize(Peer peer, boolean force) throws InterruptedException { | ||||
| 		boolean hasStatusChanged = false; | ||||
| 		BlockData priorChainTip = Controller.getInstance().getChainTip(); | ||||
|  | ||||
| 		synchronized (this.syncLock) { | ||||
| 			this.syncPercent = (priorChainTip.getHeight() * 100) / peer.getChainTipData().getLastHeight(); | ||||
|  | ||||
| 			// Only update SysTray if we're potentially changing height | ||||
| 			if (this.syncPercent < 100) { | ||||
| 				this.isSynchronizing = true; | ||||
| 				hasStatusChanged = true; | ||||
| 			} | ||||
| 		} | ||||
| 		peer.setSyncInProgress(true); | ||||
|  | ||||
| 		if (hasStatusChanged) | ||||
| 			Controller.getInstance().updateSysTray(); | ||||
|  | ||||
| 		try { | ||||
| 			SynchronizationResult syncResult = Synchronizer.getInstance().synchronize(peer, force); | ||||
| 			switch (syncResult) { | ||||
| 				case GENESIS_ONLY: | ||||
| 				case NO_COMMON_BLOCK: | ||||
| 				case TOO_DIVERGENT: | ||||
| 				case INVALID_DATA: { | ||||
| 					// These are more serious results that warrant a cool-off | ||||
| 					LOGGER.info(String.format("Failed to synchronize with peer %s (%s) - cooling off", peer, syncResult.name())); | ||||
|  | ||||
| 					// Don't use this peer again for a while | ||||
| 					Network.getInstance().peerMisbehaved(peer); | ||||
| 					break; | ||||
| 				} | ||||
|  | ||||
| 				case INFERIOR_CHAIN: { | ||||
| 					// Update our list of inferior chain tips | ||||
| 					ByteArray inferiorChainSignature = new ByteArray(peer.getChainTipData().getLastBlockSignature()); | ||||
| 					if (!inferiorChainSignatures.contains(inferiorChainSignature)) | ||||
| 						inferiorChainSignatures.add(inferiorChainSignature); | ||||
|  | ||||
| 					// These are minor failure results so fine to try again | ||||
| 					LOGGER.debug(() -> String.format("Refused to synchronize with peer %s (%s)", peer, syncResult.name())); | ||||
|  | ||||
| 					// Notify peer of our superior chain | ||||
| 					if (!peer.sendMessage(Network.getInstance().buildHeightMessage(peer, priorChainTip))) | ||||
| 						peer.disconnect("failed to notify peer of our superior chain"); | ||||
| 					break; | ||||
| 				} | ||||
|  | ||||
| 				case NO_REPLY: | ||||
| 				case NO_BLOCKCHAIN_LOCK: | ||||
| 				case REPOSITORY_ISSUE: | ||||
| 					// These are minor failure results so fine to try again | ||||
| 					LOGGER.debug(() -> String.format("Failed to synchronize with peer %s (%s)", peer, syncResult.name())); | ||||
| 					break; | ||||
|  | ||||
| 				case SHUTTING_DOWN: | ||||
| 					// Just quietly exit | ||||
| 					break; | ||||
|  | ||||
| 				case OK: | ||||
| 					// fall-through... | ||||
| 				case NOTHING_TO_DO: { | ||||
| 					// Update our list of inferior chain tips | ||||
| 					ByteArray inferiorChainSignature = new ByteArray(peer.getChainTipData().getLastBlockSignature()); | ||||
| 					if (!inferiorChainSignatures.contains(inferiorChainSignature)) | ||||
| 						inferiorChainSignatures.add(inferiorChainSignature); | ||||
|  | ||||
| 					LOGGER.debug(() -> String.format("Synchronized with peer %s (%s)", peer, syncResult.name())); | ||||
| 					break; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			if (!running) { | ||||
| 				// We've stopped | ||||
| 				return SynchronizationResult.SHUTTING_DOWN; | ||||
| 			} | ||||
|  | ||||
| 			// Has our chain tip changed? | ||||
| 			BlockData newChainTip; | ||||
|  | ||||
| 			try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 				newChainTip = repository.getBlockRepository().getLastBlock(); | ||||
| 			} catch (DataException e) { | ||||
| 				LOGGER.warn(String.format("Repository issue when trying to fetch post-synchronization chain tip: %s", e.getMessage())); | ||||
| 				return syncResult; | ||||
| 			} | ||||
|  | ||||
| 			if (!Arrays.equals(newChainTip.getSignature(), priorChainTip.getSignature())) { | ||||
| 				// Reset our cache of inferior chains | ||||
| 				inferiorChainSignatures.clear(); | ||||
|  | ||||
| 				Network network = Network.getInstance(); | ||||
| 				network.broadcast(broadcastPeer -> network.buildHeightMessage(broadcastPeer, newChainTip)); | ||||
| 			} | ||||
|  | ||||
| 			return syncResult; | ||||
| 		} finally { | ||||
| 			this.isSynchronizing = false; | ||||
| 			peer.setSyncInProgress(false); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private boolean checkRecoveryModeForPeers(List<Peer> qualifiedPeers) { | ||||
| 		List<Peer> handshakedPeers = Network.getInstance().getHandshakedPeers(); | ||||
|  | ||||
| 		if (handshakedPeers.size() > 0) { | ||||
| 			// There is at least one handshaked peer | ||||
| 			if (qualifiedPeers.isEmpty()) { | ||||
| 				// There are no 'qualified' peers - i.e. peers that have a recent block we can sync to | ||||
| 				boolean werePeersAvailable = peersAvailable; | ||||
| 				peersAvailable = false; | ||||
|  | ||||
| 				// If peers only just became unavailable, update our record of the time they were last available | ||||
| 				if (werePeersAvailable) | ||||
| 					timePeersLastAvailable = NTP.getTime(); | ||||
|  | ||||
| 				// If enough time has passed, enter recovery mode, which lifts some restrictions on who we can sync with and when we can mint | ||||
| 				if (NTP.getTime() - timePeersLastAvailable > RECOVERY_MODE_TIMEOUT) { | ||||
| 					if (recoveryMode == false) { | ||||
| 						LOGGER.info(String.format("Peers have been unavailable for %d minutes. Entering recovery mode...", RECOVERY_MODE_TIMEOUT/60/1000)); | ||||
| 						recoveryMode = true; | ||||
| 					} | ||||
| 				} | ||||
| 			} else { | ||||
| 				// We now have at least one peer with a recent block, so we can exit recovery mode and sync normally | ||||
| 				peersAvailable = true; | ||||
| 				if (recoveryMode) { | ||||
| 					LOGGER.info("Peers have become available again. Exiting recovery mode..."); | ||||
| 					recoveryMode = false; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 		return recoveryMode; | ||||
| 	} | ||||
|  | ||||
| 	public void addInferiorChainSignature(byte[] inferiorSignature) { | ||||
| 		// Update our list of inferior chain tips | ||||
| 		ByteArray inferiorChainSignature = new ByteArray(inferiorSignature); | ||||
| 		if (!inferiorChainSignatures.contains(inferiorChainSignature)) | ||||
| 			inferiorChainSignatures.add(inferiorChainSignature); | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	/** | ||||
| 	 * Iterate through a list of supplied peers, and attempt to find our common block with each. | ||||
| 	 * If a common block is found, its summary will be retained in the peer's commonBlockSummary property, for processing later. | ||||
| @@ -113,6 +412,7 @@ public class Synchronizer { | ||||
| 				LOGGER.debug(String.format("Searching for common blocks with %d peers...", peers.size())); | ||||
| 				final long startTime = System.currentTimeMillis(); | ||||
| 				int commonBlocksFound = 0; | ||||
| 				boolean wereNewRequestsMade = false; | ||||
|  | ||||
| 				for (Peer peer : peers) { | ||||
| 					// Are we shutting down? | ||||
| @@ -133,10 +433,15 @@ public class Synchronizer { | ||||
| 					Synchronizer.getInstance().findCommonBlockWithPeer(peer, repository); | ||||
| 					if (peer.getCommonBlockData() != null) | ||||
| 						commonBlocksFound++; | ||||
|  | ||||
| 					// This round wasn't served entirely from the cache, so we may want to log the results | ||||
| 					wereNewRequestsMade = true; | ||||
| 				} | ||||
|  | ||||
| 				final long totalTimeTaken = System.currentTimeMillis() - startTime; | ||||
| 				LOGGER.info(String.format("Finished searching for common blocks with %d peer%s. Found: %d. Total time taken: %d ms", peers.size(), (peers.size() != 1 ? "s" : ""), commonBlocksFound, totalTimeTaken)); | ||||
| 				if (wereNewRequestsMade) { | ||||
| 					final long totalTimeTaken = System.currentTimeMillis() - startTime; | ||||
| 					LOGGER.debug(String.format("Finished searching for common blocks with %d peer%s. Found: %d. Total time taken: %d ms", peers.size(), (peers.size() != 1 ? "s" : ""), commonBlocksFound, totalTimeTaken)); | ||||
| 				} | ||||
|  | ||||
| 				return SynchronizationResult.OK; | ||||
| 			} finally { | ||||
| @@ -174,7 +479,7 @@ public class Synchronizer { | ||||
| 					ourInitialHeight, Base58.encode(ourLastBlockSignature), ourLatestBlockData.getTimestamp())); | ||||
|  | ||||
| 			List<BlockSummaryData> peerBlockSummaries = new ArrayList<>(); | ||||
| 			SynchronizationResult findCommonBlockResult = fetchSummariesFromCommonBlock(repository, peer, ourInitialHeight, false, peerBlockSummaries); | ||||
| 			SynchronizationResult findCommonBlockResult = fetchSummariesFromCommonBlock(repository, peer, ourInitialHeight, false, peerBlockSummaries, false); | ||||
| 			if (findCommonBlockResult != SynchronizationResult.OK) { | ||||
| 				// Logging performed by fetchSummariesFromCommonBlock() above | ||||
| 				peer.setCommonBlockData(null); | ||||
| @@ -246,6 +551,8 @@ public class Synchronizer { | ||||
| 				// Create a placeholder to track of common blocks that we can discard due to being inferior chains | ||||
| 				int dropPeersAfterCommonBlockHeight = 0; | ||||
|  | ||||
| 				NumberFormat accurateFormatter = new DecimalFormat("0.################E0"); | ||||
|  | ||||
| 				// Remove peers with no common block data | ||||
| 				Iterator iterator = peers.iterator(); | ||||
| 				while (iterator.hasNext()) { | ||||
| @@ -266,7 +573,7 @@ public class Synchronizer { | ||||
| 							// We have already determined that the correct chain diverged from a lower height. We are safe to skip these peers. | ||||
| 							for (Peer peer : peersSharingCommonBlock) { | ||||
| 								LOGGER.debug(String.format("Peer %s has common block at height %d but the superior chain is at height %d. Removing it from this round.", peer, commonBlockSummary.getHeight(), dropPeersAfterCommonBlockHeight)); | ||||
| 								Controller.getInstance().addInferiorChainSignature(peer.getChainTipData().getLastBlockSignature()); | ||||
| 								this.addInferiorChainSignature(peer.getChainTipData().getLastBlockSignature()); | ||||
| 							} | ||||
| 							continue; | ||||
| 						} | ||||
| @@ -284,7 +591,9 @@ public class Synchronizer { | ||||
| 							return peers; | ||||
|  | ||||
| 						// Count the number of blocks this peer has beyond our common block | ||||
| 						final int peerHeight = peer.getChainTipData().getLastHeight(); | ||||
| 						final PeerChainTipData peerChainTipData = peer.getChainTipData(); | ||||
| 						final int peerHeight = peerChainTipData.getLastHeight(); | ||||
| 						final byte[] peerLastBlockSignature = peerChainTipData.getLastBlockSignature(); | ||||
| 						final int peerAdditionalBlocksAfterCommonBlock = peerHeight - commonBlockSummary.getHeight(); | ||||
| 						// Limit the number of blocks we are comparing. FUTURE: we could request more in batches, but there may not be a case when this is needed | ||||
| 						int summariesRequired = Math.min(peerAdditionalBlocksAfterCommonBlock, MAXIMUM_REQUEST_SIZE); | ||||
| @@ -294,7 +603,7 @@ public class Synchronizer { | ||||
| 						if (peer.canUseCachedCommonBlockData()) { | ||||
| 							if (peer.getCommonBlockData().getBlockSummariesAfterCommonBlock() != null) { | ||||
| 								if (peer.getCommonBlockData().getBlockSummariesAfterCommonBlock().size() == summariesRequired) { | ||||
| 									LOGGER.debug(String.format("Using cached block summaries for peer %s", peer)); | ||||
| 									LOGGER.trace(String.format("Using cached block summaries for peer %s", peer)); | ||||
| 									useCachedSummaries = true; | ||||
| 								} | ||||
| 							} | ||||
| @@ -304,15 +613,23 @@ public class Synchronizer { | ||||
| 							if (summariesRequired > 0) { | ||||
| 								LOGGER.trace(String.format("Requesting %d block summar%s from peer %s after common block %.8s. Peer height: %d", summariesRequired, (summariesRequired != 1 ? "ies" : "y"), peer, Base58.encode(commonBlockSummary.getSignature()), peerHeight)); | ||||
|  | ||||
| 								List<BlockSummaryData> blockSummaries = this.getBlockSummaries(peer, commonBlockSummary.getSignature(), summariesRequired); | ||||
| 								peer.getCommonBlockData().setBlockSummariesAfterCommonBlock(blockSummaries); | ||||
| 								// Forget any cached summaries | ||||
| 								peer.getCommonBlockData().setBlockSummariesAfterCommonBlock(null); | ||||
|  | ||||
| 								// Request new block summaries | ||||
| 								List<BlockSummaryData> blockSummaries = this.getBlockSummaries(peer, commonBlockSummary.getSignature(), summariesRequired); | ||||
| 								if (blockSummaries != null) { | ||||
| 									LOGGER.trace(String.format("Peer %s returned %d block summar%s", peer, blockSummaries.size(), (blockSummaries.size() != 1 ? "ies" : "y"))); | ||||
|  | ||||
| 									if (blockSummaries.size() < summariesRequired) | ||||
| 										// This could mean that the peer has re-orged. But we still have the same common block, so it's safe to proceed with this set of signatures instead. | ||||
| 										LOGGER.debug(String.format("Peer %s returned %d block summar%s instead of expected %d", peer, blockSummaries.size(), (blockSummaries.size() != 1 ? "ies" : "y"), summariesRequired)); | ||||
| 										// This could mean that the peer has re-orged. Exclude this peer until they return the summaries we expect. | ||||
| 										LOGGER.debug(String.format("Peer %s returned %d block summar%s instead of expected %d - excluding them from this round", peer, blockSummaries.size(), (blockSummaries.size() != 1 ? "ies" : "y"), summariesRequired)); | ||||
| 									else if (blockSummaryWithSignature(peerLastBlockSignature, blockSummaries) == null) | ||||
| 										// We don't have a block summary for the peer's reported chain tip, so should exclude it | ||||
| 										LOGGER.debug(String.format("Peer %s didn't return a block summary with signature %.8s - excluding them from this round", peer, Base58.encode(peerLastBlockSignature))); | ||||
| 									else | ||||
| 										// All looks good, so store the retrieved block summaries in the peer's cache | ||||
| 										peer.getCommonBlockData().setBlockSummariesAfterCommonBlock(blockSummaries); | ||||
| 								} | ||||
| 							} else { | ||||
| 								// There are no block summaries after this common block | ||||
| @@ -320,6 +637,12 @@ public class Synchronizer { | ||||
| 							} | ||||
| 						} | ||||
|  | ||||
| 						// Ignore this peer if it holds an invalid block | ||||
| 						if (this.containsInvalidBlockSummary(peer.getCommonBlockData().getBlockSummariesAfterCommonBlock())) { | ||||
| 							LOGGER.debug("Ignoring peer %s because it holds an invalid block", peer); | ||||
| 							peers.remove(peer); | ||||
| 						} | ||||
|  | ||||
| 						// Reduce minChainLength if needed. If we don't have any blocks, this peer will be excluded from chain weight comparisons later in the process, so we shouldn't update minChainLength | ||||
| 						List <BlockSummaryData> peerBlockSummaries = peer.getCommonBlockData().getBlockSummariesAfterCommonBlock(); | ||||
| 						if (peerBlockSummaries != null && peerBlockSummaries.size() > 0) | ||||
| @@ -352,9 +675,7 @@ public class Synchronizer { | ||||
| 					if (ourBlockSummaries.size() > 0) | ||||
| 						ourChainWeight = Block.calcChainWeight(commonBlockSummary.getHeight(), commonBlockSummary.getSignature(), ourBlockSummaries, maxHeightForChainWeightComparisons); | ||||
|  | ||||
| 					NumberFormat formatter = new DecimalFormat("0.###E0"); | ||||
| 					NumberFormat accurateFormatter = new DecimalFormat("0.################E0"); | ||||
| 					LOGGER.debug(String.format("Our chain weight based on %d blocks is %s", (usingSameLengthChainWeight ? minChainLength : ourBlockSummaries.size()), formatter.format(ourChainWeight))); | ||||
| 					LOGGER.debug(String.format("Our chain weight based on %d blocks is %s", (usingSameLengthChainWeight ? minChainLength : ourBlockSummaries.size()), accurateFormatter.format(ourChainWeight))); | ||||
|  | ||||
| 					LOGGER.debug(String.format("Listing peers with common block %.8s...", Base58.encode(commonBlockSummary.getSignature()))); | ||||
| 					for (Peer peer : peersSharingCommonBlock) { | ||||
| @@ -376,7 +697,7 @@ public class Synchronizer { | ||||
| 						LOGGER.debug(String.format("About to calculate chain weight based on %d blocks for peer %s with common block %.8s (peer has %d blocks after common block)", (usingSameLengthChainWeight ? minChainLength : peerBlockSummariesAfterCommonBlock.size()), peer, Base58.encode(commonBlockSummary.getSignature()), peerAdditionalBlocksAfterCommonBlock)); | ||||
| 						BigInteger peerChainWeight = Block.calcChainWeight(commonBlockSummary.getHeight(), commonBlockSummary.getSignature(), peerBlockSummariesAfterCommonBlock, maxHeightForChainWeightComparisons); | ||||
| 						peer.getCommonBlockData().setChainWeight(peerChainWeight); | ||||
| 						LOGGER.debug(String.format("Chain weight of peer %s based on %d blocks (%d - %d) is %s", peer, (usingSameLengthChainWeight ? minChainLength : peerBlockSummariesAfterCommonBlock.size()), peerBlockSummariesAfterCommonBlock.get(0).getHeight(), peerBlockSummariesAfterCommonBlock.get(peerBlockSummariesAfterCommonBlock.size()-1).getHeight(), formatter.format(peerChainWeight))); | ||||
| 						LOGGER.debug(String.format("Chain weight of peer %s based on %d blocks (%d - %d) is %s", peer, (usingSameLengthChainWeight ? minChainLength : peerBlockSummariesAfterCommonBlock.size()), peerBlockSummariesAfterCommonBlock.get(0).getHeight(), peerBlockSummariesAfterCommonBlock.get(peerBlockSummariesAfterCommonBlock.size()-1).getHeight(), accurateFormatter.format(peerChainWeight))); | ||||
|  | ||||
| 						// Compare against our chain - if our blockchain has greater weight then don't synchronize with peer (or any others in this group) | ||||
| 						if (ourChainWeight.compareTo(peerChainWeight) > 0) { | ||||
| @@ -385,8 +706,8 @@ public class Synchronizer { | ||||
| 							peers.remove(peer); | ||||
| 						} | ||||
| 						else { | ||||
| 							// Our chain is inferior | ||||
| 							LOGGER.debug(String.format("Peer %s is on a better chain to us. We will compare the other peers sharing this common block against each other, and drop all peers sharing higher common blocks.", peer)); | ||||
| 							// Our chain is inferior or equal | ||||
| 							LOGGER.debug(String.format("Peer %s is on an equal or better chain to us. We will compare the other peers sharing this common block against each other, and drop all peers sharing higher common blocks.", peer)); | ||||
| 							dropPeersAfterCommonBlockHeight = commonBlockSummary.getHeight(); | ||||
| 							superiorPeersForComparison.add(peer); | ||||
| 						} | ||||
| @@ -408,6 +729,9 @@ public class Synchronizer { | ||||
| 								peers.remove(peer); | ||||
| 							} | ||||
| 						} | ||||
| 						// FUTURE: we may want to prefer peers with additional blocks, and compare the additional blocks against each other. | ||||
| 						// This would fast track us to the best candidate for the latest block. | ||||
| 						// Right now, peers with the exact same chain as us are treated equally to those with an additional block. | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| @@ -426,14 +750,14 @@ public class Synchronizer { | ||||
|  | ||||
| 		for (Peer peer : peers) { | ||||
| 			if (peer.getCommonBlockData() != null && peer.getCommonBlockData().getCommonBlockSummary() != null) { | ||||
| 				LOGGER.debug(String.format("Peer %s has common block %.8s", peer, Base58.encode(peer.getCommonBlockData().getCommonBlockSummary().getSignature()))); | ||||
| 				LOGGER.trace(String.format("Peer %s has common block %.8s", peer, Base58.encode(peer.getCommonBlockData().getCommonBlockSummary().getSignature()))); | ||||
|  | ||||
| 				BlockSummaryData commonBlockSummary = peer.getCommonBlockData().getCommonBlockSummary(); | ||||
| 				if (!commonBlocks.contains(commonBlockSummary)) | ||||
| 					commonBlocks.add(commonBlockSummary); | ||||
| 			} | ||||
| 			else { | ||||
| 				LOGGER.debug(String.format("Peer %s has no common block data. Skipping...", peer)); | ||||
| 				LOGGER.trace(String.format("Peer %s has no common block data. Skipping...", peer)); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| @@ -453,6 +777,77 @@ public class Synchronizer { | ||||
| 		return minChainLength; | ||||
| 	} | ||||
|  | ||||
| 	private BlockSummaryData blockSummaryWithSignature(byte[] signature, List<BlockSummaryData> blockSummaries) { | ||||
| 		if (blockSummaries != null) | ||||
| 			return blockSummaries.stream().filter(blockSummary -> Arrays.equals(blockSummary.getSignature(), signature)).findAny().orElse(null); | ||||
| 		return null; | ||||
| 	} | ||||
|  | ||||
|  | ||||
|  | ||||
| 	/* Invalid block signature tracking */ | ||||
|  | ||||
| 	private void addInvalidBlockSignature(byte[] signature) { | ||||
| 		Long now = NTP.getTime(); | ||||
| 		if (now == null) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		// Add or update existing entry | ||||
| 		String sig58 = Base58.encode(signature); | ||||
| 		invalidBlockSignatures.put(sig58, now); | ||||
| 	} | ||||
| 	private void deleteOlderInvalidSignatures(Long now) { | ||||
| 		if (now == null) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		// Delete signatures with older timestamps | ||||
| 		Iterator it = invalidBlockSignatures.entrySet().iterator(); | ||||
| 		while (it.hasNext()) { | ||||
| 			Map.Entry pair = (Map.Entry)it.next(); | ||||
| 			Long lastSeen = (Long) pair.getValue(); | ||||
|  | ||||
| 			// Remove signature if we haven't seen it for more than 1 hour | ||||
| 			if (now - lastSeen > 60 * 60 * 1000L) { | ||||
| 				it.remove(); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	private boolean containsInvalidBlockSummary(List<BlockSummaryData> blockSummaries) { | ||||
| 		if (blockSummaries == null || invalidBlockSignatures == null) { | ||||
| 			return false; | ||||
| 		} | ||||
|  | ||||
| 		// Loop through our known invalid blocks and check each one against supplied block summaries | ||||
| 		for (String invalidSignature58 : invalidBlockSignatures.keySet()) { | ||||
| 			byte[] invalidSignature = Base58.decode(invalidSignature58); | ||||
| 			for (BlockSummaryData blockSummary : blockSummaries) { | ||||
| 				byte[] signature = blockSummary.getSignature(); | ||||
| 				if (Arrays.equals(signature, invalidSignature)) { | ||||
| 					return true; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 		return false; | ||||
| 	} | ||||
| 	private boolean containsInvalidBlockSignature(List<byte[]> blockSignatures) { | ||||
| 		if (blockSignatures == null || invalidBlockSignatures == null) { | ||||
| 			return false; | ||||
| 		} | ||||
|  | ||||
| 		// Loop through our known invalid blocks and check each one against supplied block signatures | ||||
| 		for (String invalidSignature58 : invalidBlockSignatures.keySet()) { | ||||
| 			byte[] invalidSignature = Base58.decode(invalidSignature58); | ||||
| 			for (byte[] signature : blockSignatures) { | ||||
| 				if (Arrays.equals(signature, invalidSignature)) { | ||||
| 					return true; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 		return false; | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	/** | ||||
| 	 * Attempt to synchronize blockchain with peer. | ||||
| @@ -468,9 +863,11 @@ public class Synchronizer { | ||||
| 		// Make sure we're the only thread modifying the blockchain | ||||
| 		// If we're already synchronizing with another peer then this will also return fast | ||||
| 		ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); | ||||
| 		if (!blockchainLock.tryLock()) | ||||
| 		if (!blockchainLock.tryLock(3, TimeUnit.SECONDS)) { | ||||
| 			// Wasn't peer's fault we couldn't sync | ||||
| 			LOGGER.info("Synchronizer couldn't acquire blockchain lock"); | ||||
| 			return SynchronizationResult.NO_BLOCKCHAIN_LOCK; | ||||
| 		} | ||||
|  | ||||
| 		try { | ||||
| 			try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| @@ -483,12 +880,25 @@ public class Synchronizer { | ||||
| 					byte[] peersLastBlockSignature = peerChainTipData.getLastBlockSignature(); | ||||
|  | ||||
| 					byte[] ourLastBlockSignature = ourLatestBlockData.getSignature(); | ||||
| 					LOGGER.debug(String.format("Synchronizing with peer %s at height %d, sig %.8s, ts %d; our height %d, sig %.8s, ts %d", peer, | ||||
| 					String syncString = String.format("Synchronizing with peer %s at height %d, sig %.8s, ts %d; our height %d, sig %.8s, ts %d", peer, | ||||
| 							peerHeight, Base58.encode(peersLastBlockSignature), peer.getChainTipData().getLastBlockTimestamp(), | ||||
| 							ourInitialHeight, Base58.encode(ourLastBlockSignature), ourLatestBlockData.getTimestamp())); | ||||
| 							ourInitialHeight, Base58.encode(ourLastBlockSignature), ourLatestBlockData.getTimestamp()); | ||||
| 					LOGGER.info(syncString); | ||||
|  | ||||
| 					// Reset last re-org size as we are starting a new sync round | ||||
| 					this.lastReorgSize = 0; | ||||
|  | ||||
| 					// Set the initial value of timeValidBlockLastReceived if it's null | ||||
| 					Long now = NTP.getTime(); | ||||
| 					if (this.timeValidBlockLastReceived == null) { | ||||
| 						this.timeValidBlockLastReceived = now; | ||||
| 					} | ||||
|  | ||||
| 					// Delete invalid signatures with older timestamps | ||||
| 					this.deleteOlderInvalidSignatures(now); | ||||
|  | ||||
| 					List<BlockSummaryData> peerBlockSummaries = new ArrayList<>(); | ||||
| 					SynchronizationResult findCommonBlockResult = fetchSummariesFromCommonBlock(repository, peer, ourInitialHeight, force, peerBlockSummaries); | ||||
| 					SynchronizationResult findCommonBlockResult = fetchSummariesFromCommonBlock(repository, peer, ourInitialHeight, force, peerBlockSummaries, true); | ||||
| 					if (findCommonBlockResult != SynchronizationResult.OK) { | ||||
| 						// Logging performed by fetchSummariesFromCommonBlock() above | ||||
| 						// Clear our common block cache for this peer | ||||
| @@ -544,10 +954,19 @@ public class Synchronizer { | ||||
| 					// Commit | ||||
| 					repository.saveChanges(); | ||||
|  | ||||
| 					// Create string for logging | ||||
| 					final BlockData newLatestBlockData = repository.getBlockRepository().getLastBlock(); | ||||
| 					LOGGER.info(String.format("Synchronized with peer %s to height %d, sig %.8s, ts: %d", peer, | ||||
| 					String syncLog = String.format("Synchronized with peer %s to height %d, sig %.8s, ts: %d", peer, | ||||
| 							newLatestBlockData.getHeight(), Base58.encode(newLatestBlockData.getSignature()), | ||||
| 							newLatestBlockData.getTimestamp())); | ||||
| 							newLatestBlockData.getTimestamp()); | ||||
|  | ||||
| 					// Append re-org info | ||||
| 					if (this.lastReorgSize > 0) { | ||||
| 						syncLog = syncLog.concat(String.format(", size: %d", this.lastReorgSize)); | ||||
| 					} | ||||
|  | ||||
| 					// Log sync info | ||||
| 					LOGGER.info(syncLog); | ||||
|  | ||||
| 					return SynchronizationResult.OK; | ||||
| 				} finally { | ||||
| @@ -570,7 +989,7 @@ public class Synchronizer { | ||||
| 	 * @throws DataException | ||||
| 	 * @throws InterruptedException | ||||
| 	 */ | ||||
| 	public SynchronizationResult fetchSummariesFromCommonBlock(Repository repository, Peer peer, int ourHeight, boolean force, List<BlockSummaryData> blockSummariesFromCommon) throws DataException, InterruptedException { | ||||
| 	public SynchronizationResult fetchSummariesFromCommonBlock(Repository repository, Peer peer, int ourHeight, boolean force, List<BlockSummaryData> blockSummariesFromCommon, boolean infoLogWhenNotFound) throws DataException, InterruptedException { | ||||
| 		// Start by asking for a few recent block hashes as this will cover a majority of reorgs | ||||
| 		// Failing that, back off exponentially | ||||
| 		int step = INITIAL_BLOCK_STEP; | ||||
| @@ -599,8 +1018,12 @@ public class Synchronizer { | ||||
| 			blockSummariesBatch = this.getBlockSummaries(peer, testSignature, step); | ||||
|  | ||||
| 			if (blockSummariesBatch == null) { | ||||
| 				if (infoLogWhenNotFound) | ||||
| 					LOGGER.info(String.format("Error while trying to find common block with peer %s", peer)); | ||||
| 				else | ||||
| 					LOGGER.debug(String.format("Error while trying to find common block with peer %s", peer)); | ||||
| 				 | ||||
| 				// No response - give up this time | ||||
| 				LOGGER.info(String.format("Error while trying to find common block with peer %s", peer)); | ||||
| 				return SynchronizationResult.NO_REPLY; | ||||
| 			} | ||||
|  | ||||
| @@ -664,7 +1087,7 @@ public class Synchronizer { | ||||
| 			return SynchronizationResult.REPOSITORY_ISSUE; | ||||
|  | ||||
| 		if (ourLatestBlockData.getTimestamp() < minLatestBlockTimestamp) { | ||||
| 			LOGGER.info(String.format("Ditching our chain after height %d as our latest block is very old", commonBlockHeight)); | ||||
| 			LOGGER.info(String.format("Ditching our chain after height %d", commonBlockHeight)); | ||||
| 		} else { | ||||
| 			// Compare chain weights | ||||
|  | ||||
| @@ -724,8 +1147,9 @@ public class Synchronizer { | ||||
| 			BigInteger ourChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockSig, ourBlockSummaries, mutualHeight); | ||||
| 			BigInteger peerChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockSig, peerBlockSummaries, mutualHeight); | ||||
|  | ||||
| 			NumberFormat formatter = new DecimalFormat("0.###E0"); | ||||
| 			LOGGER.debug(String.format("Our chain weight: %s, peer's chain weight: %s (higher is better)", formatter.format(ourChainWeight), formatter.format(peerChainWeight))); | ||||
| 			NumberFormat accurateFormatter = new DecimalFormat("0.################E0"); | ||||
| 			LOGGER.debug(String.format("commonBlockHeight: %d, commonBlockSig: %.8s, ourBlockSummaries.size(): %d, peerBlockSummaries.size(): %d", commonBlockHeight, Base58.encode(commonBlockSig), ourBlockSummaries.size(), peerBlockSummaries.size())); | ||||
| 			LOGGER.debug(String.format("Our chain weight: %s, peer's chain weight: %s (higher is better)", accurateFormatter.format(ourChainWeight), accurateFormatter.format(peerChainWeight))); | ||||
|  | ||||
| 			// If our blockchain has greater weight then don't synchronize with peer | ||||
| 			if (ourChainWeight.compareTo(peerChainWeight) >= 0) { | ||||
| @@ -738,7 +1162,7 @@ public class Synchronizer { | ||||
| 	} | ||||
|  | ||||
| 	private SynchronizationResult syncToPeerChain(Repository repository, BlockData commonBlockData, int ourInitialHeight, | ||||
| 			Peer peer, final int peerHeight, List<BlockSummaryData> peerBlockSummaries) throws DataException, InterruptedException { | ||||
| 												  Peer peer, final int peerHeight, List<BlockSummaryData> peerBlockSummaries) throws DataException, InterruptedException { | ||||
| 		final int commonBlockHeight = commonBlockData.getHeight(); | ||||
| 		final byte[] commonBlockSig = commonBlockData.getSignature(); | ||||
| 		String commonBlockSig58 = Base58.encode(commonBlockSig); | ||||
| @@ -748,6 +1172,7 @@ public class Synchronizer { | ||||
|  | ||||
| 		LOGGER.debug(() -> String.format("Fetching peer %s chain from height %d, sig %.8s", peer, commonBlockHeight, commonBlockSig58)); | ||||
|  | ||||
| 		final int maxRetries = Settings.getInstance().getMaxRetries(); | ||||
|  | ||||
| 		// Overall plan: fetch peer's blocks first, then orphan, then apply | ||||
|  | ||||
| @@ -767,38 +1192,32 @@ public class Synchronizer { | ||||
| 			if (Controller.isStopping()) | ||||
| 				return SynchronizationResult.SHUTTING_DOWN; | ||||
|  | ||||
|             // Ensure we don't request more than MAXIMUM_REQUEST_SIZE | ||||
|             int numberRequested = Math.min(numberSignaturesRequired, MAXIMUM_REQUEST_SIZE); | ||||
| 			// Ensure we don't request more than MAXIMUM_REQUEST_SIZE | ||||
| 			int numberRequested = Math.min(numberSignaturesRequired, MAXIMUM_REQUEST_SIZE); | ||||
|  | ||||
|             // Do we need more signatures? | ||||
| 			// Do we need more signatures? | ||||
| 			if (peerBlockSignatures.isEmpty() && numberRequested > 0) { | ||||
|                 LOGGER.trace(String.format("Requesting %d signature%s after height %d, sig %.8s", | ||||
|                         numberRequested, (numberRequested != 1 ? "s" : ""), height, Base58.encode(latestPeerSignature))); | ||||
| 				LOGGER.trace(String.format("Requesting %d signature%s after height %d, sig %.8s", | ||||
| 						numberRequested, (numberRequested != 1 ? "s" : ""), height, Base58.encode(latestPeerSignature))); | ||||
|  | ||||
|                 peerBlockSignatures = this.getBlockSignatures(peer, latestPeerSignature, numberRequested); | ||||
| 				peerBlockSignatures = this.getBlockSignatures(peer, latestPeerSignature, numberRequested); | ||||
|  | ||||
|                 if (peerBlockSignatures == null || peerBlockSignatures.isEmpty()) { | ||||
|                     LOGGER.info(String.format("Peer %s failed to respond with more block signatures after height %d, sig %.8s", peer, | ||||
|                             height, Base58.encode(latestPeerSignature))); | ||||
| 				if (peerBlockSignatures == null || peerBlockSignatures.isEmpty()) { | ||||
| 					LOGGER.info(String.format("Peer %s failed to respond with more block signatures after height %d, sig %.8s", peer, | ||||
| 							height, Base58.encode(latestPeerSignature))); | ||||
|  | ||||
| 					// Clear our cache of common block summaries for this peer, as they are likely to be invalid | ||||
| 					CommonBlockData cachedCommonBlockData = peer.getCommonBlockData(); | ||||
| 					if (cachedCommonBlockData != null) | ||||
| 						cachedCommonBlockData.setBlockSummariesAfterCommonBlock(null); | ||||
|  | ||||
|                     // If we have already received recent or newer blocks from this peer, go ahead and apply them | ||||
|                     // If we have already received newer blocks from this peer that what we have already, go ahead and apply them | ||||
|                     if (peerBlocks.size() > 0) { | ||||
| 						final BlockData ourLatestBlockData = repository.getBlockRepository().getLastBlock(); | ||||
|                     	final Block peerLatestBlock = peerBlocks.get(peerBlocks.size() - 1); | ||||
| 						final Block peerLatestBlock = peerBlocks.get(peerBlocks.size() - 1); | ||||
| 						final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); | ||||
| 						if (ourLatestBlockData != null && peerLatestBlock != null && minLatestBlockTimestamp != null) { | ||||
|  | ||||
| 							// If we have received at least one recent block, we can apply them | ||||
| 							if (peerLatestBlock.getBlockData().getTimestamp() > minLatestBlockTimestamp) { | ||||
| 								LOGGER.debug("Newly received blocks are recent, so we will apply them"); | ||||
| 								break; | ||||
| 							} | ||||
|  | ||||
| 							// If our latest block is very old.... | ||||
| 							if (ourLatestBlockData.getTimestamp() < minLatestBlockTimestamp) { | ||||
| 								// ... and we have received a block that is more recent than our latest block ... | ||||
| @@ -814,12 +1233,12 @@ public class Synchronizer { | ||||
| 							} | ||||
| 						} | ||||
|                     } | ||||
|                     // Otherwise, give up and move on to the next peer, to avoid putting our chain into an outdated state | ||||
| 					// Otherwise, give up and move on to the next peer, to avoid putting our chain into an outdated or incomplete state | ||||
|                     return SynchronizationResult.NO_REPLY; | ||||
|                 } | ||||
|  | ||||
|                 numberSignaturesRequired = peerHeight - height - peerBlockSignatures.size(); | ||||
|                 LOGGER.trace(String.format("Received %s signature%s", peerBlockSignatures.size(), (peerBlockSignatures.size() != 1 ? "s" : ""))); | ||||
| 				numberSignaturesRequired = peerHeight - height - peerBlockSignatures.size(); | ||||
| 				LOGGER.trace(String.format("Received %s signature%s", peerBlockSignatures.size(), (peerBlockSignatures.size() != 1 ? "s" : ""))); | ||||
| 			} | ||||
|  | ||||
| 			if (peerBlockSignatures.isEmpty()) { | ||||
| @@ -827,6 +1246,12 @@ public class Synchronizer { | ||||
| 				break; | ||||
| 			} | ||||
|  | ||||
| 			// Catch a block with an invalid signature before orphaning, so that we retain our existing valid candidate | ||||
| 			if (this.containsInvalidBlockSignature(peerBlockSignatures)) { | ||||
| 				LOGGER.info(String.format("Peer %s sent invalid block signature: %.8s", peer, Base58.encode(latestPeerSignature))); | ||||
| 				return SynchronizationResult.INVALID_DATA; | ||||
| 			} | ||||
|  | ||||
| 			byte[] nextPeerSignature = peerBlockSignatures.get(0); | ||||
| 			int nextHeight = height + 1; | ||||
|  | ||||
| @@ -837,21 +1262,14 @@ public class Synchronizer { | ||||
| 				LOGGER.info(String.format("Peer %s failed to respond with block for height %d, sig %.8s", peer, | ||||
| 						nextHeight, Base58.encode(nextPeerSignature))); | ||||
|  | ||||
| 				if (retryCount >= MAXIMUM_RETRIES) { | ||||
|  | ||||
| 					// If we have already received recent or newer blocks from this peer, go ahead and apply them | ||||
| 				if (retryCount >= maxRetries) { | ||||
| 					// If we have already received newer blocks from this peer that what we have already, go ahead and apply them | ||||
| 					if (peerBlocks.size() > 0) { | ||||
| 						final BlockData ourLatestBlockData = repository.getBlockRepository().getLastBlock(); | ||||
| 						final Block peerLatestBlock = peerBlocks.get(peerBlocks.size() - 1); | ||||
| 						final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); | ||||
| 						if (ourLatestBlockData != null && peerLatestBlock != null && minLatestBlockTimestamp != null) { | ||||
|  | ||||
| 							// If we have received at least one recent block, we can apply them | ||||
| 							if (peerLatestBlock.getBlockData().getTimestamp() > minLatestBlockTimestamp) { | ||||
| 								LOGGER.debug("Newly received blocks are recent, so we will apply them"); | ||||
| 								break; | ||||
| 							} | ||||
|  | ||||
| 							// If our latest block is very old.... | ||||
| 							if (ourLatestBlockData.getTimestamp() < minLatestBlockTimestamp) { | ||||
| 								// ... and we have received a block that is more recent than our latest block ... | ||||
| @@ -867,7 +1285,7 @@ public class Synchronizer { | ||||
| 							} | ||||
| 						} | ||||
| 					} | ||||
| 					// Otherwise, give up and move on to the next peer, to avoid putting our chain into an outdated state | ||||
| 					// Otherwise, give up and move on to the next peer, to avoid putting our chain into an outdated or incomplete state | ||||
| 					return SynchronizationResult.NO_REPLY; | ||||
|  | ||||
| 				} else { | ||||
| @@ -875,9 +1293,9 @@ public class Synchronizer { | ||||
| 					peerBlockSignatures.clear(); | ||||
| 					numberSignaturesRequired = peerHeight - height; | ||||
|  | ||||
| 					// Retry until retryCount reaches MAXIMUM_RETRIES | ||||
| 					// Retry until retryCount reaches maxRetries | ||||
| 					retryCount++; | ||||
| 					int triesRemaining = MAXIMUM_RETRIES - retryCount; | ||||
| 					int triesRemaining = maxRetries - retryCount; | ||||
| 					LOGGER.info(String.format("Re-issuing request to peer %s (%d attempt%s remaining)", peer, triesRemaining, (triesRemaining != 1 ? "s" : ""))); | ||||
| 					continue; | ||||
| 				} | ||||
| @@ -909,6 +1327,7 @@ public class Synchronizer { | ||||
| 		// Unwind to common block (unless common block is our latest block) | ||||
| 		int ourHeight = ourInitialHeight; | ||||
| 		LOGGER.debug(String.format("Orphaning blocks back to common block height %d, sig %.8s. Our height: %d", commonBlockHeight, commonBlockSig58, ourHeight)); | ||||
| 		int reorgSize = ourHeight - commonBlockHeight; | ||||
|  | ||||
| 		BlockData orphanBlockData = repository.getBlockRepository().fromHeight(ourInitialHeight); | ||||
| 		while (ourHeight > commonBlockHeight) { | ||||
| @@ -935,13 +1354,20 @@ public class Synchronizer { | ||||
| 			if (Controller.isStopping()) | ||||
| 				return SynchronizationResult.SHUTTING_DOWN; | ||||
|  | ||||
| 			newBlock.preProcess(); | ||||
|  | ||||
| 			ValidationResult blockResult = newBlock.isValid(); | ||||
| 			if (blockResult != ValidationResult.OK) { | ||||
| 				LOGGER.info(String.format("Peer %s sent invalid block for height %d, sig %.8s: %s", peer, | ||||
| 						newBlock.getBlockData().getHeight(), Base58.encode(newBlock.getSignature()), blockResult.name())); | ||||
| 				this.addInvalidBlockSignature(newBlock.getSignature()); | ||||
| 				this.timeInvalidBlockLastReceived = NTP.getTime(); | ||||
| 				return SynchronizationResult.INVALID_DATA; | ||||
| 			} | ||||
|  | ||||
| 			// Block is valid | ||||
| 			this.timeValidBlockLastReceived = NTP.getTime(); | ||||
|  | ||||
| 			// Save transactions attached to this block | ||||
| 			for (Transaction transaction : newBlock.getTransactions()) { | ||||
| 				TransactionData transactionData = transaction.getTransactionData(); | ||||
| @@ -957,11 +1383,12 @@ public class Synchronizer { | ||||
| 			Controller.getInstance().onNewBlock(newBlock.getBlockData()); | ||||
| 		} | ||||
|  | ||||
| 		this.lastReorgSize = reorgSize; | ||||
| 		return SynchronizationResult.OK; | ||||
| 	} | ||||
|  | ||||
| 	private SynchronizationResult applyNewBlocks(Repository repository, BlockData commonBlockData, int ourInitialHeight, | ||||
| 			Peer peer, int peerHeight, List<BlockSummaryData> peerBlockSummaries) throws InterruptedException, DataException { | ||||
| 												 Peer peer, int peerHeight, List<BlockSummaryData> peerBlockSummaries) throws InterruptedException, DataException { | ||||
| 		LOGGER.debug(String.format("Fetching new blocks from peer %s", peer)); | ||||
|  | ||||
| 		final int commonBlockHeight = commonBlockData.getHeight(); | ||||
| @@ -1022,13 +1449,20 @@ public class Synchronizer { | ||||
| 			for (Transaction transaction : newBlock.getTransactions()) | ||||
| 				transaction.setInitialApprovalStatus(); | ||||
|  | ||||
| 			newBlock.preProcess(); | ||||
|  | ||||
| 			ValidationResult blockResult = newBlock.isValid(); | ||||
| 			if (blockResult != ValidationResult.OK) { | ||||
| 				LOGGER.info(String.format("Peer %s sent invalid block for height %d, sig %.8s: %s", peer, | ||||
| 						ourHeight, Base58.encode(latestPeerSignature), blockResult.name())); | ||||
| 				this.addInvalidBlockSignature(newBlock.getSignature()); | ||||
| 				this.timeInvalidBlockLastReceived = NTP.getTime(); | ||||
| 				return SynchronizationResult.INVALID_DATA; | ||||
| 			} | ||||
|  | ||||
| 			// Block is valid | ||||
| 			this.timeValidBlockLastReceived = NTP.getTime(); | ||||
|  | ||||
| 			// Save transactions attached to this block | ||||
| 			for (Transaction transaction : newBlock.getTransactions()) { | ||||
| 				TransactionData transactionData = transaction.getTransactionData(); | ||||
| @@ -1083,7 +1517,7 @@ public class Synchronizer { | ||||
| 		return new Block(repository, blockMessage.getBlockData(), blockMessage.getTransactions(), blockMessage.getAtStates()); | ||||
| 	} | ||||
|  | ||||
| 	private void populateBlockSummariesMinterLevels(Repository repository, List<BlockSummaryData> blockSummaries) throws DataException { | ||||
| 	public void populateBlockSummariesMinterLevels(Repository repository, List<BlockSummaryData> blockSummaries) throws DataException { | ||||
| 		final int firstBlockHeight = blockSummaries.get(0).getHeight(); | ||||
|  | ||||
| 		for (int i = 0; i < blockSummaries.size(); ++i) { | ||||
|   | ||||
| @@ -0,0 +1,185 @@ | ||||
| package org.qortal.controller.arbitrary; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.arbitrary.ArbitraryDataBuildQueueItem; | ||||
| import org.qortal.utils.NTP; | ||||
|  | ||||
| import java.util.Collections; | ||||
| import java.util.HashMap; | ||||
| import java.util.Map; | ||||
| import java.util.concurrent.ExecutorService; | ||||
| import java.util.concurrent.Executors; | ||||
|  | ||||
| public class ArbitraryDataBuildManager extends Thread { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataBuildManager.class); | ||||
|  | ||||
|     private static ArbitraryDataBuildManager instance; | ||||
|  | ||||
|     private volatile boolean isStopping = false; | ||||
|     private boolean buildInProgress = false; | ||||
|  | ||||
|     /** | ||||
|      * Map to keep track of arbitrary transaction resources currently being built (or queued). | ||||
|      */ | ||||
|     public Map<String, ArbitraryDataBuildQueueItem> arbitraryDataBuildQueue = Collections.synchronizedMap(new HashMap<>()); | ||||
|  | ||||
|     /** | ||||
|      * Map to keep track of failed arbitrary transaction builds. | ||||
|      */ | ||||
|     public Map<String, ArbitraryDataBuildQueueItem> arbitraryDataFailedBuilds = Collections.synchronizedMap(new HashMap<>()); | ||||
|  | ||||
|  | ||||
|     public ArbitraryDataBuildManager() { | ||||
|  | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void run() { | ||||
|         try { | ||||
|             // Use a fixed thread pool to execute the arbitrary data build actions (currently just a single thread) | ||||
|             // This can be expanded to have multiple threads processing the build queue when needed | ||||
|             ExecutorService arbitraryDataBuildExecutor = Executors.newFixedThreadPool(1); | ||||
|             arbitraryDataBuildExecutor.execute(new ArbitraryDataBuilderThread()); | ||||
|  | ||||
|             while (!isStopping) { | ||||
|                 // Nothing to do yet | ||||
|                 Thread.sleep(5000); | ||||
|             } | ||||
|  | ||||
|         } catch (InterruptedException e) { | ||||
|             // Fall-through to exit thread... | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static ArbitraryDataBuildManager getInstance() { | ||||
|         if (instance == null) | ||||
|             instance = new ArbitraryDataBuildManager(); | ||||
|  | ||||
|         return instance; | ||||
|     } | ||||
|  | ||||
|     public void shutdown() { | ||||
|         isStopping = true; | ||||
|         this.interrupt(); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public void cleanupQueues(Long now) { | ||||
|         if (now == null) { | ||||
|             return; | ||||
|         } | ||||
|         arbitraryDataBuildQueue.entrySet().removeIf(entry -> entry.getValue().hasReachedBuildTimeout(now)); | ||||
|         arbitraryDataFailedBuilds.entrySet().removeIf(entry -> entry.getValue().hasReachedFailureTimeout(now)); | ||||
|     } | ||||
|  | ||||
|     // Build queue | ||||
|  | ||||
|     public boolean addToBuildQueue(ArbitraryDataBuildQueueItem queueItem) { | ||||
|         String key = queueItem.getUniqueKey(); | ||||
|         if (key == null) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (this.arbitraryDataBuildQueue == null) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (NTP.getTime() == null) { | ||||
|             // Can't use queues until we have synced the time | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // Don't add builds that have failed recently | ||||
|         if (this.isInFailedBuildsList(queueItem)) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (this.arbitraryDataBuildQueue.put(key, queueItem) != null) { | ||||
|             // Already in queue | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         LOGGER.info("Added {} to build queue", queueItem); | ||||
|  | ||||
|         // Added to queue | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public boolean isInBuildQueue(ArbitraryDataBuildQueueItem queueItem) { | ||||
|         String key = queueItem.getUniqueKey(); | ||||
|         if (key == null) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (this.arbitraryDataBuildQueue == null) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (this.arbitraryDataBuildQueue.containsKey(key)) { | ||||
|             // Already in queue | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         // Not in queue | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|  | ||||
|     // Failed builds | ||||
|  | ||||
|     public boolean addToFailedBuildsList(ArbitraryDataBuildQueueItem queueItem) { | ||||
|         String key = queueItem.getUniqueKey(); | ||||
|         if (key == null) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (this.arbitraryDataFailedBuilds == null) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (NTP.getTime() == null) { | ||||
|             // Can't use queues until we have synced the time | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (this.arbitraryDataFailedBuilds.put(key, queueItem) != null) { | ||||
|             // Already in list | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         LOGGER.info("Added {} to failed builds list", queueItem); | ||||
|  | ||||
|         // Added to queue | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public boolean isInFailedBuildsList(ArbitraryDataBuildQueueItem queueItem) { | ||||
|         String key = queueItem.getUniqueKey(); | ||||
|         if (key == null) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (this.arbitraryDataFailedBuilds == null) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (this.arbitraryDataFailedBuilds.containsKey(key)) { | ||||
|             // Already in list | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         // Not in list | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public void setBuildInProgress(boolean buildInProgress) { | ||||
|         this.buildInProgress = buildInProgress; | ||||
|     } | ||||
|  | ||||
|     public boolean getBuildInProgress() { | ||||
|         return this.buildInProgress; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,99 @@ | ||||
| package org.qortal.controller.arbitrary; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| 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.repository.DataException; | ||||
| import org.qortal.utils.NTP; | ||||
|  | ||||
| import java.io.IOException; | ||||
| import java.util.Map; | ||||
|  | ||||
|  | ||||
| public class ArbitraryDataBuilderThread implements Runnable { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataBuilderThread.class); | ||||
|  | ||||
|     public ArbitraryDataBuilderThread() { | ||||
|  | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void run() { | ||||
|         Thread.currentThread().setName("Arbitrary Data Builder Thread"); | ||||
|         ArbitraryDataBuildManager buildManager = ArbitraryDataBuildManager.getInstance(); | ||||
|  | ||||
|         while (!Controller.isStopping()) { | ||||
|             try { | ||||
|                 Thread.sleep(1000); | ||||
|  | ||||
|                 if (buildManager.arbitraryDataBuildQueue == null) { | ||||
|                     continue; | ||||
|                 } | ||||
|                 if (buildManager.arbitraryDataBuildQueue.isEmpty()) { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 // Find resources that are queued for building | ||||
|                 Map.Entry<String, ArbitraryDataBuildQueueItem> next = buildManager.arbitraryDataBuildQueue | ||||
|                         .entrySet().stream() | ||||
|                         .filter(e -> e.getValue().isQueued()) | ||||
|                         .findFirst().orElse(null); | ||||
|  | ||||
|                 if (next == null) { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 Long now = NTP.getTime(); | ||||
|                 if (now == null) { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 ArbitraryDataBuildQueueItem queueItem = next.getValue(); | ||||
|  | ||||
|                 if (queueItem == null) { | ||||
|                     this.removeFromQueue(queueItem); | ||||
|                 } | ||||
|  | ||||
|                 // Ignore builds that have failed recently | ||||
|                 if (buildManager.isInFailedBuildsList(queueItem)) { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|  | ||||
|                 try { | ||||
|                     // Perform the build | ||||
|                     LOGGER.info("Building {}...", queueItem); | ||||
|                     queueItem.build(); | ||||
|                     this.removeFromQueue(queueItem); | ||||
|                     LOGGER.info("Finished building {}", queueItem); | ||||
|  | ||||
|                 } catch (MissingDataException e) { | ||||
|                     LOGGER.info("Missing data for {}: {}", queueItem, e.getMessage()); | ||||
|                     queueItem.setFailed(true); | ||||
|                     this.removeFromQueue(queueItem); | ||||
|                     // Don't add to the failed builds list, as we may want to retry sooner | ||||
|  | ||||
|                 } catch (IOException | DataException | RuntimeException e) { | ||||
|                     LOGGER.info("Error building {}: {}", queueItem, e.getMessage()); | ||||
|                     // Something went wrong - so remove it from the queue, and add to failed builds list | ||||
|                     queueItem.setFailed(true); | ||||
|                     buildManager.addToFailedBuildsList(queueItem); | ||||
|                     this.removeFromQueue(queueItem); | ||||
|                 } | ||||
|  | ||||
|             } catch (InterruptedException e) { | ||||
|                 // Time to exit | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void removeFromQueue(ArbitraryDataBuildQueueItem queueItem) { | ||||
|         if (queueItem == null || queueItem.getUniqueKey() == null) { | ||||
|             return; | ||||
|         } | ||||
|         ArbitraryDataBuildManager.getInstance().arbitraryDataBuildQueue.remove(queueItem.getUniqueKey()); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,576 @@ | ||||
| package org.qortal.controller.arbitrary; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; | ||||
| import org.qortal.data.transaction.ArbitraryTransactionData; | ||||
| import org.qortal.data.transaction.TransactionData; | ||||
| 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.transaction.Transaction.TransactionType; | ||||
| import org.qortal.utils.ArbitraryTransactionUtils; | ||||
| import org.qortal.utils.Base58; | ||||
| import org.qortal.utils.FilesystemUtils; | ||||
| import org.qortal.utils.NTP; | ||||
|  | ||||
| import java.io.File; | ||||
| import java.io.IOException; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.Path; | ||||
| import java.nio.file.Paths; | ||||
| import java.security.SecureRandom; | ||||
| import java.util.*; | ||||
|  | ||||
| import static org.qortal.controller.arbitrary.ArbitraryDataStorageManager.DELETION_THRESHOLD; | ||||
|  | ||||
| public class ArbitraryDataCleanupManager extends Thread { | ||||
|  | ||||
| 	private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataCleanupManager.class); | ||||
| 	private static final List<TransactionType> ARBITRARY_TX_TYPE = Arrays.asList(TransactionType.ARBITRARY); | ||||
|  | ||||
| 	private static ArbitraryDataCleanupManager instance; | ||||
|  | ||||
| 	private volatile boolean isStopping = false; | ||||
|  | ||||
| 	/** | ||||
| 	 * The amount of time that must pass before a file is treated as stale / not recent. | ||||
| 	 * We can safely delete files created/accessed longer ago that this, if we have a means of | ||||
| 	 * rebuilding them. The main purpose of this is to avoid deleting files that are currently | ||||
| 	 * being used by other parts of the system. | ||||
| 	 */ | ||||
| 	private static final long STALE_FILE_TIMEOUT = 60*60*1000L; // 1 hour | ||||
|  | ||||
| 	/** | ||||
| 	 * The number of chunks to delete in a batch when over the capacity limit. | ||||
| 	 * Storage limits are re-checked after each batch, and there could be a significant | ||||
| 	 * delay between the processing of each batch as it only occurs after a complete | ||||
| 	 * cleanup cycle (to allow unwanted chunks to be deleted first). | ||||
| 	 */ | ||||
| 	private static final int CHUNK_DELETION_BATCH_SIZE = 10; | ||||
|  | ||||
|  | ||||
| 	/* | ||||
| 	TODO: | ||||
| 	- Delete files from the _misc folder once they reach a certain age | ||||
| 	 */ | ||||
|  | ||||
|  | ||||
| 	private ArbitraryDataCleanupManager() { | ||||
| 	} | ||||
|  | ||||
| 	public static ArbitraryDataCleanupManager getInstance() { | ||||
| 		if (instance == null) | ||||
| 			instance = new ArbitraryDataCleanupManager(); | ||||
|  | ||||
| 		return instance; | ||||
| 	} | ||||
|  | ||||
| 	@Override | ||||
| 	public void run() { | ||||
| 		Thread.currentThread().setName("Arbitrary Data Cleanup Manager"); | ||||
|  | ||||
| 		// Paginate queries when fetching arbitrary transactions | ||||
| 		final int limit = 100; | ||||
| 		int offset = 0; | ||||
|  | ||||
| 		try { | ||||
| 			while (!isStopping) { | ||||
| 				Thread.sleep(30000); | ||||
|  | ||||
| 				// Don't run if QDN is disabled | ||||
| 				if (!Settings.getInstance().isQdnEnabled()) { | ||||
| 					Thread.sleep(60 * 60 * 1000L); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				Long now = NTP.getTime(); | ||||
| 				if (now == null) { | ||||
| 					// Don't attempt to make decisions if we haven't synced our time yet | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				ArbitraryDataStorageManager storageManager = ArbitraryDataStorageManager.getInstance(); | ||||
|  | ||||
| 				// Wait until storage capacity has been calculated | ||||
| 				if (!storageManager.isStorageCapacityCalculated()) { | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				// Periodically delete any unnecessary files from the temp directory | ||||
| 				if (offset == 0 || offset % (limit * 10) == 0) { | ||||
| 					this.cleanupTempDirectory(now); | ||||
| 				} | ||||
|  | ||||
| 				// Any arbitrary transactions we want to fetch data for? | ||||
| 				try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 					List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, ARBITRARY_TX_TYPE, null, null, null, ConfirmationStatus.BOTH, limit, offset, true); | ||||
| 					// LOGGER.info("Found {} arbitrary transactions at offset: {}, limit: {}", signatures.size(), offset, limit); | ||||
| 					if (isStopping) { | ||||
| 						return; | ||||
| 					} | ||||
|  | ||||
| 					if (signatures == null || signatures.isEmpty()) { | ||||
| 						offset = 0; | ||||
| 						continue; | ||||
| 					} | ||||
| 					offset += limit; | ||||
| 					now = NTP.getTime(); | ||||
|  | ||||
| 					// Loop through the signatures in this batch | ||||
| 					for (int i=0; i<signatures.size(); i++) { | ||||
| 						if (isStopping) { | ||||
| 							return; | ||||
| 						} | ||||
|  | ||||
| 						byte[] signature = signatures.get(i); | ||||
| 						if (signature == null) { | ||||
| 							continue; | ||||
| 						} | ||||
|  | ||||
| 						// Don't interfere with the filesystem whilst a build is in progress | ||||
| 						if (ArbitraryDataBuildManager.getInstance().getBuildInProgress()) { | ||||
| 							Thread.sleep(5000); | ||||
| 						} | ||||
|  | ||||
| 						// Fetch the transaction data | ||||
| 						ArbitraryTransactionData arbitraryTransactionData = ArbitraryTransactionUtils.fetchTransactionData(repository, signature); | ||||
| 						if (arbitraryTransactionData == null) { | ||||
| 							continue; | ||||
| 						} | ||||
|  | ||||
| 						// Raw data doesn't have any associated files to clean up | ||||
| 						if (arbitraryTransactionData.getDataType() == ArbitraryTransactionData.DataType.RAW_DATA) { | ||||
| 							continue; | ||||
| 						} | ||||
|  | ||||
| 						// Check if we have the complete file | ||||
| 						boolean completeFileExists = ArbitraryTransactionUtils.completeFileExists(arbitraryTransactionData); | ||||
|  | ||||
| 						// Check if we have any of the chunks | ||||
| 						boolean anyChunksExist = ArbitraryTransactionUtils.anyChunksExist(arbitraryTransactionData); | ||||
| 						boolean transactionHasChunks = (arbitraryTransactionData.getMetadataHash() != null); | ||||
|  | ||||
| 						if (!completeFileExists && !anyChunksExist) { | ||||
| 							// We don't have any files at all for this transaction - nothing to do | ||||
| 							continue; | ||||
| 						} | ||||
|  | ||||
| 						// We have at least 1 chunk or file for this transaction, so we might need to delete them... | ||||
|  | ||||
|  | ||||
| 						// Check to see if we should be hosting data for this transaction at all | ||||
| 						if (!storageManager.canStoreData(arbitraryTransactionData)) { | ||||
| 							LOGGER.info("Deleting transaction {} because we can't host its data", | ||||
| 									Base58.encode(arbitraryTransactionData.getSignature())); | ||||
| 							ArbitraryTransactionUtils.deleteCompleteFileAndChunks(arbitraryTransactionData); | ||||
| 							continue; | ||||
| 						} | ||||
|  | ||||
| 						// Check to see if we have had a more recent PUT | ||||
| 						boolean hasMoreRecentPutTransaction = ArbitraryTransactionUtils.hasMoreRecentPutTransaction(repository, arbitraryTransactionData); | ||||
| 						if (hasMoreRecentPutTransaction) { | ||||
| 							// There is a more recent PUT transaction than the one we are currently processing. | ||||
| 							// When a PUT is issued, it replaces any layers that would have been there before. | ||||
| 							// Therefore any data relating to this older transaction is no longer needed. | ||||
| 							LOGGER.info(String.format("Newer PUT found for %s %s since transaction %s. " + | ||||
| 											"Deleting all files associated with the earlier transaction.", arbitraryTransactionData.getService(), | ||||
| 									arbitraryTransactionData.getName(), Base58.encode(signature))); | ||||
|  | ||||
| 							ArbitraryTransactionUtils.deleteCompleteFileAndChunks(arbitraryTransactionData); | ||||
|  | ||||
| 							// We should also remove peers for this transaction from the lookup table to save space | ||||
| 							this.removePeersHostingTransactionData(repository, arbitraryTransactionData); | ||||
| 							continue; | ||||
| 						} | ||||
|  | ||||
| 						if (completeFileExists && !transactionHasChunks) { | ||||
| 							// This file doesn't have any chunks because it is too small. | ||||
| 							// We must not delete anything. | ||||
| 							continue; | ||||
| 						} | ||||
|  | ||||
| 						// Check if we have all of the chunks | ||||
| 						boolean allChunksExist = ArbitraryTransactionUtils.allChunksExist(arbitraryTransactionData); | ||||
|  | ||||
| 						if (completeFileExists && allChunksExist) { | ||||
| 							// We have the complete file and all the chunks, so we can delete | ||||
| 							// the complete file if it has reached a certain age. | ||||
| 							LOGGER.debug(String.format("Transaction %s has complete file and all chunks", | ||||
| 									Base58.encode(arbitraryTransactionData.getSignature()))); | ||||
|  | ||||
| 							ArbitraryTransactionUtils.deleteCompleteFile(arbitraryTransactionData, now, STALE_FILE_TIMEOUT); | ||||
| 							continue; | ||||
| 						} | ||||
|  | ||||
| 						if (completeFileExists && !allChunksExist) { | ||||
| 							// We have the complete file but not the chunks, so let's convert it | ||||
| 							LOGGER.info(String.format("Transaction %s has complete file but no chunks", | ||||
| 									Base58.encode(arbitraryTransactionData.getSignature()))); | ||||
|  | ||||
| 							ArbitraryTransactionUtils.convertFileToChunks(arbitraryTransactionData, now, STALE_FILE_TIMEOUT); | ||||
| 							continue; | ||||
| 						} | ||||
| 					} | ||||
|  | ||||
| 				} catch (DataException e) { | ||||
| 					LOGGER.error("Repository issue when fetching arbitrary transaction data", e); | ||||
| 				} | ||||
|  | ||||
| 				try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|  | ||||
| 					// Check if there are any hosted files that don't have matching transactions | ||||
| 					this.checkForExpiredTransactions(repository); | ||||
|  | ||||
| 					// Delete additional data at random if we're over our storage limit | ||||
| 					// Use the DELETION_THRESHOLD so that we only start deleting once the hard limit is reached | ||||
| 					// This also allows some headroom between the regular threshold (90%) and the hard | ||||
| 					// limit, to avoid data getting into a fetch/delete loop. | ||||
| 					if (!storageManager.isStorageSpaceAvailable(DELETION_THRESHOLD)) { | ||||
|  | ||||
| 						// Rate limit, to avoid repeated calls to calculateDirectorySize() | ||||
| 						Thread.sleep(60000); | ||||
| 						// Now delete some data at random | ||||
| 						this.storageLimitReached(repository); | ||||
| 					} | ||||
|  | ||||
| 					// 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()) { | ||||
| 						if (isStopping) { | ||||
| 							return; | ||||
| 						} | ||||
| 						if (!storageManager.isStorageSpaceAvailableForName(repository, followedName, DELETION_THRESHOLD)) { | ||||
| 							this.storageLimitReachedForName(repository, followedName); | ||||
| 						} | ||||
| 					} | ||||
|  | ||||
| 				} catch (DataException e) { | ||||
| 					LOGGER.error("Repository issue when cleaning up arbitrary transaction data", e); | ||||
| 				} | ||||
| 			} | ||||
| 		} catch (InterruptedException e) { | ||||
| 			// Fall-through to exit thread... | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public List<Path> findPathsWithNoAssociatedTransaction(Repository repository) { | ||||
| 		List<Path> pathList = new ArrayList<>(); | ||||
|  | ||||
| 		// Find all hosted paths | ||||
| 		List<Path> allPaths = ArbitraryDataStorageManager.getInstance().findAllHostedPaths(); | ||||
|  | ||||
| 		// Loop through each path and find those without matching signatures | ||||
| 		for (Path path : allPaths) { | ||||
| 			if (isStopping) { | ||||
| 				break; | ||||
| 			} | ||||
| 			try { | ||||
| 				String[] contents = path.toFile().list(); | ||||
| 				if (contents == null || contents.length == 0) { | ||||
| 					// Ignore empty directories | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				String signature58 = path.getFileName().toString(); | ||||
| 				byte[] signature = Base58.decode(signature58); | ||||
| 				TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); | ||||
| 				if (transactionData == null) { | ||||
| 					// No transaction data, and no DataException, so we can assume that this data relates to an expired transaction | ||||
| 					pathList.add(path); | ||||
| 				} | ||||
|  | ||||
| 			} catch (DataException e) { | ||||
| 				continue; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return pathList; | ||||
| 	} | ||||
|  | ||||
| 	private void checkForExpiredTransactions(Repository repository) { | ||||
| 		List<Path> expiredPaths = this.findPathsWithNoAssociatedTransaction(repository); | ||||
| 		for (Path expiredPath : expiredPaths) { | ||||
| 			if (isStopping) { | ||||
| 				return; | ||||
| 			} | ||||
| 			LOGGER.info("Found path with no associated transaction: {}", expiredPath.toString()); | ||||
| 			this.safeDeleteDirectory(expiredPath.toFile(), "no matching transaction"); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private void storageLimitReached(Repository repository) throws InterruptedException { | ||||
| 		// We think that the storage limit has been reached | ||||
|  | ||||
| 		// Now calculate the used/total storage again, as a safety precaution | ||||
| 		Long now = NTP.getTime(); | ||||
| 		ArbitraryDataStorageManager.getInstance().calculateDirectorySize(now); | ||||
| 		if (ArbitraryDataStorageManager.getInstance().isStorageSpaceAvailable(DELETION_THRESHOLD)) { | ||||
| 			// We have space available, so don't delete anything | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		// Delete a batch of random chunks | ||||
| 		// This reduces the chance of too many nodes deleting the same chunk | ||||
| 		// when they reach their storage limit | ||||
| 		Path dataPath = Paths.get(Settings.getInstance().getDataPath()); | ||||
| 		for (int i=0; i<CHUNK_DELETION_BATCH_SIZE; i++) { | ||||
| 			if (isStopping) { | ||||
| 				return; | ||||
| 			} | ||||
| 			this.deleteRandomFile(repository, dataPath.toFile(), null); | ||||
| 		} | ||||
|  | ||||
| 		// FUTURE: consider reducing the expiry time of the reader cache | ||||
| 	} | ||||
|  | ||||
| 	public void storageLimitReachedForName(Repository repository, String name) throws InterruptedException { | ||||
| 		// We think that the storage limit has been reached for supplied name - but we should double check | ||||
| 		if (ArbitraryDataStorageManager.getInstance().isStorageSpaceAvailableForName(repository, name, DELETION_THRESHOLD)) { | ||||
| 			// We have space available for this name, so don't delete anything | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		// Delete a batch of random chunks associated with this name | ||||
| 		// This reduces the chance of too many nodes deleting the same chunk | ||||
| 		// when they reach their storage limit | ||||
| 		Path dataPath = Paths.get(Settings.getInstance().getDataPath()); | ||||
| 		for (int i=0; i<CHUNK_DELETION_BATCH_SIZE; i++) { | ||||
| 			if (isStopping) { | ||||
| 				return; | ||||
| 			} | ||||
| 			this.deleteRandomFile(repository, dataPath.toFile(), name); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Iteratively walk through given directory and delete a single random file | ||||
| 	 * | ||||
| 	 * @param directory - the base directory | ||||
| 	 * @return boolean - whether a file was deleted | ||||
| 	 */ | ||||
| 	private boolean deleteRandomFile(Repository repository, File directory, String name) { | ||||
| 		Path tempDataPath = Paths.get(Settings.getInstance().getTempDataPath()); | ||||
|  | ||||
| 		// Pick a random directory | ||||
| 		final File[] contentsList = directory.listFiles(); | ||||
| 		if (contentsList != null) { | ||||
| 			SecureRandom random = new SecureRandom(); | ||||
|  | ||||
| 			// If the directory is empty, there's nothing to do | ||||
| 			if (contentsList.length == 0) { | ||||
| 				return false; | ||||
| 			} | ||||
|  | ||||
| 			File randomItem = contentsList[random.nextInt(contentsList.length)]; | ||||
|  | ||||
| 			// Skip anything relating to the temp directory | ||||
| 			if (FilesystemUtils.isChild(randomItem.toPath(), tempDataPath)) { | ||||
| 				return false; | ||||
| 			} | ||||
| 			// Make sure it exists | ||||
| 			if (!randomItem.exists()) { | ||||
| 				return false; | ||||
| 			} | ||||
|  | ||||
| 			// If it's a directory, iteratively repeat the process | ||||
| 			if (randomItem.isDirectory()) { | ||||
| 				return this.deleteRandomFile(repository, randomItem, name); | ||||
| 			} | ||||
|  | ||||
| 			// If it's a file, we might be able to delete it | ||||
| 			if (randomItem.isFile()) { | ||||
|  | ||||
| 				// If the parent directory contains an ".original" file, don't delete anything | ||||
| 				// This indicates that the content was originally updated by this node and so | ||||
| 				// could be the only copy that exists. | ||||
| 				Path originalCopyIndicatorPath = Paths.get(randomItem.getParent(), ".original"); | ||||
| 				if (Files.exists(originalCopyIndicatorPath)) { | ||||
| 					// This is an original seed copy and so shouldn't be deleted | ||||
| 					return false; | ||||
| 				} | ||||
|  | ||||
| 				if (name != null) { | ||||
| 					// A name has been specified, so we need to make sure this file relates to | ||||
| 					// the name we want to delete. The signature should be the name of parent directory. | ||||
| 					try { | ||||
| 						Path parentFileNamePath = randomItem.toPath().toAbsolutePath().getParent().getFileName(); | ||||
| 						if (parentFileNamePath != null) { | ||||
| 							String signature58 = parentFileNamePath.toString(); | ||||
| 							byte[] signature = Base58.decode(signature58); | ||||
| 							TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); | ||||
| 							if (transactionData == null || transactionData.getType() != Transaction.TransactionType.ARBITRARY) { | ||||
| 								// Not what we were expecting, so don't delete it | ||||
| 								return false; | ||||
| 							} | ||||
| 							ArbitraryTransactionData arbitraryTransactionData = (ArbitraryTransactionData) transactionData; | ||||
| 							if (!Objects.equals(arbitraryTransactionData.getName(), name)) { | ||||
| 								// Relates to a different name - don't delete it | ||||
| 								return false; | ||||
| 							} | ||||
| 						} | ||||
|  | ||||
| 					} catch (DataException e) { | ||||
| 						// Something went wrong and we weren't able to make a decision - so it's best not to delete this file | ||||
| 						return false; | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				LOGGER.info("Deleting random file {} because we have reached max storage capacity...", randomItem.toString()); | ||||
| 				boolean success = randomItem.delete(); | ||||
| 				if (success) { | ||||
| 					try { | ||||
| 						FilesystemUtils.safeDeleteEmptyParentDirectories(randomItem.toPath().getParent()); | ||||
| 					} catch (IOException e) { | ||||
| 						// Ignore cleanup failure | ||||
| 					} | ||||
| 				} | ||||
| 				return success; | ||||
| 			} | ||||
| 		} | ||||
| 		return false; | ||||
| 	} | ||||
|  | ||||
| 	private void removePeersHostingTransactionData(Repository repository, ArbitraryTransactionData transactionData) { | ||||
| 		byte[] signature = transactionData.getSignature(); | ||||
| 		try { | ||||
| 			repository.getArbitraryRepository().deleteArbitraryPeersWithSignature(signature); | ||||
| 			repository.saveChanges(); | ||||
| 		} catch (DataException e) { | ||||
| 			LOGGER.debug("Unable to delete peers from lookup table for signature: {}", Base58.encode(signature)); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private void cleanupTempDirectory(String folder, long now, long minAge) { | ||||
| 		String baseDir = Settings.getInstance().getTempDataPath(); | ||||
| 		Path tempDir = Paths.get(baseDir, folder); | ||||
| 		int contentsCount = 0; | ||||
|  | ||||
| 		// Loop through the contents and check each one | ||||
| 		final File[] directories = tempDir.toFile().listFiles(); | ||||
| 		if (directories != null) { | ||||
| 			for (final File directory : directories) { | ||||
| 				if (isStopping) { | ||||
| 					return; | ||||
| 				} | ||||
| 				contentsCount++; | ||||
|  | ||||
| 				// We're expecting the contents of each subfolder to be a directory | ||||
| 				if (directory.isDirectory()) { | ||||
| 					if (!ArbitraryTransactionUtils.isFileRecent(directory.toPath(), now, minAge)) { | ||||
| 						// File isn't recent, so can be deleted | ||||
| 						this.safeDeleteDirectory(directory, "not recent"); | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// If the directory is empty, we still need to delete its parent folder | ||||
| 		if (contentsCount == 0 && tempDir.toFile().isDirectory() && tempDir.toFile().exists()) { | ||||
| 			try { | ||||
| 				LOGGER.debug("Parent directory {} is empty, so deleting it", tempDir); | ||||
| 				FilesystemUtils.safeDeleteDirectory(tempDir, false); | ||||
| 			} catch(IOException e){ | ||||
| 				LOGGER.info("Unable to delete parent directory: {}", tempDir); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private void cleanupReaderCache(Long now) { | ||||
| 		ArbitraryDataStorageManager storageManager = ArbitraryDataStorageManager.getInstance(); | ||||
| 		String baseDir = Settings.getInstance().getTempDataPath(); | ||||
| 		Path readerCachePath = Paths.get(baseDir, "reader"); | ||||
|  | ||||
| 		// Clean up names | ||||
| 		Path readerCacheNamesPath = Paths.get(readerCachePath.toString(), "NAME"); | ||||
|  | ||||
| 		// Loop through the contents and check each one | ||||
| 		final File[] directories = readerCacheNamesPath.toFile().listFiles(); | ||||
| 		if (directories != null) { | ||||
| 			for (final File directory : directories) { | ||||
| 				if (isStopping) { | ||||
| 					return; | ||||
| 				} | ||||
|  | ||||
| 				// Delete data relating to blocked names | ||||
| 				String name = directory.getName(); | ||||
| 				if (name != null && storageManager.isNameBlocked(name)) { | ||||
| 					this.safeDeleteDirectory(directory, "blocked name"); | ||||
| 				} | ||||
|  | ||||
| 				// Delete cached reader data that has reached its expiry | ||||
| 				this.cleanupReaderCacheForName(name, now); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private void cleanupReaderCacheForName(String name, Long now) { | ||||
| 		if (name == null) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		String baseDir = Settings.getInstance().getTempDataPath(); | ||||
| 		Path readerNameCachePath = Paths.get(baseDir, "reader", "NAME", name); | ||||
|  | ||||
| 		// Loop through the contents and check each one | ||||
| 		final File[] directories = readerNameCachePath.toFile().listFiles(); | ||||
| 		if (directories != null) { | ||||
| 			for (final File directory : directories) { | ||||
| 				if (isStopping) { | ||||
| 					return; | ||||
| 				} | ||||
| 				// Each directory is a "service" type | ||||
| 				String service = directory.getName(); | ||||
| 				this.cleanupReaderCacheForNameAndService(name, service, now); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private void cleanupReaderCacheForNameAndService(String name, String service, Long now) { | ||||
| 		if (name == null || service == null) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		Path readerNameServiceCachePath = Paths.get("reader", "NAME", name, service); | ||||
| 		Long expiry = Settings.getInstance().getBuiltDataExpiryInterval(); | ||||
| 		this.cleanupTempDirectory(readerNameServiceCachePath.toString(), now, expiry); | ||||
| 	} | ||||
|  | ||||
| 	private void cleanupTempDirectory(long now) { | ||||
|  | ||||
| 		// Use the "stale file timeout" for the intermediate directories. | ||||
| 		// These aren't used for serving content - only for building it. | ||||
| 		// Once the files have become stale, it's safe to delete them. | ||||
| 		this.cleanupTempDirectory("diff",  now, STALE_FILE_TIMEOUT); | ||||
| 		this.cleanupTempDirectory("join",  now, STALE_FILE_TIMEOUT); | ||||
| 		this.cleanupTempDirectory("merge",  now, STALE_FILE_TIMEOUT); | ||||
| 		this.cleanupTempDirectory("writer",  now, STALE_FILE_TIMEOUT); | ||||
|  | ||||
| 		// Built resources are served out of the "reader" directory so these | ||||
| 		// need to be kept around for much longer. | ||||
| 		// Purging currently disabled, as it's not very helpful. Will revisit | ||||
| 		// once we implement local storage limits. | ||||
| 		this.cleanupReaderCache(now); | ||||
|  | ||||
| 	} | ||||
|  | ||||
| 	private boolean safeDeleteDirectory(File directory, String reason) { | ||||
| 		LOGGER.info("Deleting directory {} due to reason: {}", directory, reason); | ||||
| 		try { | ||||
| 			FilesystemUtils.safeDeleteDirectory(directory.toPath(), true); | ||||
| 			return true; | ||||
| 		} catch (IOException e) { | ||||
| 			LOGGER.debug("Unable to delete directory: {}", directory); | ||||
| 		} | ||||
| 		return false; | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	public void shutdown() { | ||||
| 		isStopping = true; | ||||
| 		this.interrupt(); | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,636 @@ | ||||
| package org.qortal.controller.arbitrary; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.arbitrary.ArbitraryDataFile; | ||||
| import org.qortal.arbitrary.ArbitraryDataFileChunk; | ||||
| import org.qortal.controller.Controller; | ||||
| import org.qortal.data.arbitrary.ArbitraryRelayInfo; | ||||
| import org.qortal.data.transaction.ArbitraryTransactionData; | ||||
| import org.qortal.data.transaction.TransactionData; | ||||
| import org.qortal.network.Network; | ||||
| import org.qortal.network.Peer; | ||||
| import org.qortal.network.message.ArbitraryDataFileListMessage; | ||||
| import org.qortal.network.message.GetArbitraryDataFileListMessage; | ||||
| import org.qortal.network.message.Message; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.RepositoryManager; | ||||
| import org.qortal.settings.Settings; | ||||
| import org.qortal.utils.Base58; | ||||
| import org.qortal.utils.NTP; | ||||
| import org.qortal.utils.Triple; | ||||
|  | ||||
| import java.util.*; | ||||
|  | ||||
| public class ArbitraryDataFileListManager { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataFileListManager.class); | ||||
|  | ||||
|     private static ArbitraryDataFileListManager instance; | ||||
|  | ||||
|  | ||||
|     /** | ||||
|      * Map of recent incoming requests for ARBITRARY transaction data file lists. | ||||
|      * <p> | ||||
|      * Key is original request's message ID<br> | ||||
|      * Value is Triple<transaction signature in base58, first requesting peer, first request's timestamp> | ||||
|      * <p> | ||||
|      * If peer is null then either:<br> | ||||
|      * <ul> | ||||
|      * <li>we are the original requesting peer</li> | ||||
|      * <li>we have already sent data payload to original requesting peer.</li> | ||||
|      * </ul> | ||||
|      * If signature is null then we have already received the file list and either:<br> | ||||
|      * <ul> | ||||
|      * <li>we are the original requesting peer and have processed it</li> | ||||
|      * <li>we have forwarded the file list</li> | ||||
|      * </ul> | ||||
|      */ | ||||
|     public Map<Integer, Triple<String, Peer, Long>> arbitraryDataFileListRequests = Collections.synchronizedMap(new HashMap<>()); | ||||
|  | ||||
|     /** | ||||
|      * Map to keep track of in progress arbitrary data signature requests | ||||
|      * Key: string - the signature encoded in base58 | ||||
|      * Value: Triple<networkBroadcastCount, directPeerRequestCount, lastAttemptTimestamp> | ||||
|      */ | ||||
|     private Map<String, Triple<Integer, Integer, Long>> arbitraryDataSignatureRequests = Collections.synchronizedMap(new HashMap<>()); | ||||
|  | ||||
|  | ||||
|     /** Maximum number of seconds that a file list relay request is able to exist on the network */ | ||||
|     private static long RELAY_REQUEST_MAX_DURATION = 5000L; | ||||
|     /** Maximum number of hops that a file list relay request is allowed to make */ | ||||
|     private static int RELAY_REQUEST_MAX_HOPS = 4; | ||||
|  | ||||
|  | ||||
|     private ArbitraryDataFileListManager() { | ||||
|     } | ||||
|  | ||||
|     public static ArbitraryDataFileListManager getInstance() { | ||||
|         if (instance == null) | ||||
|             instance = new ArbitraryDataFileListManager(); | ||||
|  | ||||
|         return instance; | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public void cleanupRequestCache(Long now) { | ||||
|         if (now == null) { | ||||
|             return; | ||||
|         } | ||||
|         final long requestMinimumTimestamp = now - ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT; | ||||
|         arbitraryDataFileListRequests.entrySet().removeIf(entry -> entry.getValue().getC() == null || entry.getValue().getC() < requestMinimumTimestamp); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     // Track file list lookups by signature | ||||
|  | ||||
|     private boolean shouldMakeFileListRequestForSignature(String signature58) { | ||||
|         Triple<Integer, Integer, Long> request = arbitraryDataSignatureRequests.get(signature58); | ||||
|  | ||||
|         if (request == null) { | ||||
|             // Not attempted yet | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         // Extract the components | ||||
|         Integer networkBroadcastCount = request.getA(); | ||||
|         // Integer directPeerRequestCount = request.getB(); | ||||
|         Long lastAttemptTimestamp = request.getC(); | ||||
|  | ||||
|         if (lastAttemptTimestamp == null) { | ||||
|             // Not attempted yet | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         long timeSinceLastAttempt = NTP.getTime() - lastAttemptTimestamp; | ||||
|  | ||||
|         // Allow a second attempt after 15 seconds, and another after 30 seconds | ||||
|         if (timeSinceLastAttempt > 15 * 1000L) { | ||||
|             // We haven't tried for at least 15 seconds | ||||
|  | ||||
|             if (networkBroadcastCount < 3) { | ||||
|                 // We've made less than 3 total attempts | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Then allow another 5 attempts, each 5 minutes apart | ||||
|         if (timeSinceLastAttempt > 5 * 60 * 1000L) { | ||||
|             // We haven't tried for at least 5 minutes | ||||
|  | ||||
|             if (networkBroadcastCount < 5) { | ||||
|                 // We've made less than 5 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 | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     private boolean shouldMakeDirectFileRequestsForSignature(String signature58) { | ||||
|         if (!Settings.getInstance().isDirectDataRetrievalEnabled()) { | ||||
|             // Direct connections are disabled in the settings | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         Triple<Integer, Integer, Long> request = arbitraryDataSignatureRequests.get(signature58); | ||||
|  | ||||
|         if (request == null) { | ||||
|             // Not attempted yet | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         // Extract the components | ||||
|         //Integer networkBroadcastCount = request.getA(); | ||||
|         Integer directPeerRequestCount = request.getB(); | ||||
|         Long lastAttemptTimestamp = request.getC(); | ||||
|  | ||||
|         if (lastAttemptTimestamp == null) { | ||||
|             // Not attempted yet | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (directPeerRequestCount == 0) { | ||||
|             // We haven't tried asking peers directly yet, so we should | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         long timeSinceLastAttempt = NTP.getTime() - lastAttemptTimestamp; | ||||
|         if (timeSinceLastAttempt > 10 * 1000L) { | ||||
|             // We haven't tried for at least 10 seconds | ||||
|             if (directPeerRequestCount < 5) { | ||||
|                 // We've made less than 5 total attempts | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (timeSinceLastAttempt > 5 * 60 * 1000L) { | ||||
|             // We haven't tried for at least 5 minutes | ||||
|             if (directPeerRequestCount < 10) { | ||||
|                 // We've made less than 10 total attempts | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (timeSinceLastAttempt > 24 * 60 * 60 * 1000L) { | ||||
|             // We haven't tried for at least 24 hours | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public boolean isSignatureRateLimited(byte[] signature) { | ||||
|         String signature58 = Base58.encode(signature); | ||||
|         return !this.shouldMakeFileListRequestForSignature(signature58) | ||||
|                 && !this.shouldMakeDirectFileRequestsForSignature(signature58); | ||||
|     } | ||||
|  | ||||
|     public long lastRequestForSignature(byte[] signature) { | ||||
|         String signature58 = Base58.encode(signature); | ||||
|         Triple<Integer, Integer, Long> request = arbitraryDataSignatureRequests.get(signature58); | ||||
|  | ||||
|         if (request == null) { | ||||
|             // Not attempted yet | ||||
|             return 0; | ||||
|         } | ||||
|  | ||||
|         // Extract the components | ||||
|         Long lastAttemptTimestamp = request.getC(); | ||||
|         if (lastAttemptTimestamp != null) { | ||||
|             return  lastAttemptTimestamp; | ||||
|         } | ||||
|         return 0; | ||||
|     } | ||||
|  | ||||
|     public void addToSignatureRequests(String signature58, boolean incrementNetworkRequests, boolean incrementPeerRequests) { | ||||
|         Triple<Integer, Integer, Long> request  = arbitraryDataSignatureRequests.get(signature58); | ||||
|         Long now = NTP.getTime(); | ||||
|  | ||||
|         if (request == null) { | ||||
|             // No entry yet | ||||
|             Triple<Integer, Integer, Long> newRequest = new Triple<>(0, 0, now); | ||||
|             arbitraryDataSignatureRequests.put(signature58, newRequest); | ||||
|         } | ||||
|         else { | ||||
|             // There is an existing entry | ||||
|             if (incrementNetworkRequests) { | ||||
|                 request.setA(request.getA() + 1); | ||||
|             } | ||||
|             if (incrementPeerRequests) { | ||||
|                 request.setB(request.getB() + 1); | ||||
|             } | ||||
|             request.setC(now); | ||||
|             arbitraryDataSignatureRequests.put(signature58, request); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void removeFromSignatureRequests(String signature58) { | ||||
|         arbitraryDataSignatureRequests.remove(signature58); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     // Lookup file lists by signature (and optionally hashes) | ||||
|  | ||||
|     public boolean fetchArbitraryDataFileList(ArbitraryTransactionData arbitraryTransactionData) { | ||||
|         byte[] digest = arbitraryTransactionData.getData(); | ||||
|         byte[] metadataHash = arbitraryTransactionData.getMetadataHash(); | ||||
|         byte[] signature = arbitraryTransactionData.getSignature(); | ||||
|         String signature58 = Base58.encode(signature); | ||||
|  | ||||
|         // Require an NTP sync | ||||
|         Long now = NTP.getTime(); | ||||
|         if (now == null) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // If we've already tried too many times in a short space of time, make sure to give up | ||||
|         if (!this.shouldMakeFileListRequestForSignature(signature58)) { | ||||
|             // Check if we should make direct connections to peers | ||||
|             if (this.shouldMakeDirectFileRequestsForSignature(signature58)) { | ||||
|                 return ArbitraryDataFileManager.getInstance().fetchDataFilesFromPeersForSignature(signature); | ||||
|             } | ||||
|  | ||||
|             LOGGER.trace("Skipping file list request for signature {} due to rate limit", signature58); | ||||
|             return false; | ||||
|         } | ||||
|         this.addToSignatureRequests(signature58, true, false); | ||||
|  | ||||
|         List<Peer> handshakedPeers = Network.getInstance().getHandshakedPeers(); | ||||
|         List<byte[]> missingHashes = null; | ||||
|  | ||||
| //        // TODO: uncomment after GetArbitraryDataFileListMessage updates are deployed | ||||
| //        // Find hashes that we are missing | ||||
| //        try { | ||||
| //            ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(digest, signature); | ||||
| //            arbitraryDataFile.setMetadataHash(metadataHash); | ||||
| //            missingHashes = arbitraryDataFile.missingHashes(); | ||||
| //        } catch (DataException e) { | ||||
| //            // Leave missingHashes as null, so that all hashes are requested | ||||
| //        } | ||||
| //        int hashCount = missingHashes != null ? missingHashes.size() : 0; | ||||
|  | ||||
|         int hashCount = 0; | ||||
|         LOGGER.debug(String.format("Sending data file list request for signature %s with %d hashes to %d peers...", signature58, hashCount, handshakedPeers.size())); | ||||
|  | ||||
|         // Build request | ||||
|         Message getArbitraryDataFileListMessage = new GetArbitraryDataFileListMessage(signature, missingHashes, now, 0); | ||||
|  | ||||
|         // Save our request into requests map | ||||
|         Triple<String, Peer, Long> requestEntry = new Triple<>(signature58, null, NTP.getTime()); | ||||
|  | ||||
|         // Assign random ID to this message | ||||
|         int id; | ||||
|         do { | ||||
|             id = new Random().nextInt(Integer.MAX_VALUE - 1) + 1; | ||||
|  | ||||
|             // Put queue into map (keyed by message ID) so we can poll for a response | ||||
|             // If putIfAbsent() doesn't return null, then this ID is already taken | ||||
|         } while (arbitraryDataFileListRequests.put(id, requestEntry) != null); | ||||
|         getArbitraryDataFileListMessage.setId(id); | ||||
|  | ||||
|         // Broadcast request | ||||
|         Network.getInstance().broadcast(peer -> getArbitraryDataFileListMessage); | ||||
|  | ||||
|         // Poll to see if data has arrived | ||||
|         final long singleWait = 100; | ||||
|         long totalWait = 0; | ||||
|         while (totalWait < ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT) { | ||||
|             try { | ||||
|                 Thread.sleep(singleWait); | ||||
|             } catch (InterruptedException e) { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             requestEntry = arbitraryDataFileListRequests.get(id); | ||||
|             if (requestEntry == null) | ||||
|                 return false; | ||||
|  | ||||
|             if (requestEntry.getA() == null) | ||||
|                 break; | ||||
|  | ||||
|             totalWait += singleWait; | ||||
|         } | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public boolean fetchArbitraryDataFileList(Peer peer, byte[] signature) { | ||||
|         String signature58 = Base58.encode(signature); | ||||
|  | ||||
|         // Require an NTP sync | ||||
|         Long now = NTP.getTime(); | ||||
|         if (now == null) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         int hashCount = 0; | ||||
|         LOGGER.debug(String.format("Sending data file list request for signature %s with %d hashes to peer %s...", signature58, hashCount, peer)); | ||||
|  | ||||
|         // Build request | ||||
|         // Use a time in the past, so that the recipient peer doesn't try and relay it | ||||
|         // Also, set hashes to null since it's easier to request all hashes than it is to determine which ones we need | ||||
|         // This could be optimized in the future | ||||
|         long timestamp = now - 60000L; | ||||
|         List<byte[]> hashes = null; | ||||
|         Message getArbitraryDataFileListMessage = new GetArbitraryDataFileListMessage(signature, hashes, timestamp, 0); | ||||
|  | ||||
|         // Save our request into requests map | ||||
|         Triple<String, Peer, Long> requestEntry = new Triple<>(signature58, null, NTP.getTime()); | ||||
|  | ||||
|         // Assign random ID to this message | ||||
|         int id; | ||||
|         do { | ||||
|             id = new Random().nextInt(Integer.MAX_VALUE - 1) + 1; | ||||
|  | ||||
|             // Put queue into map (keyed by message ID) so we can poll for a response | ||||
|             // If putIfAbsent() doesn't return null, then this ID is already taken | ||||
|         } while (arbitraryDataFileListRequests.put(id, requestEntry) != null); | ||||
|         getArbitraryDataFileListMessage.setId(id); | ||||
|  | ||||
|         // Send the request | ||||
|         peer.sendMessage(getArbitraryDataFileListMessage); | ||||
|  | ||||
|         // Poll to see if data has arrived | ||||
|         final long singleWait = 100; | ||||
|         long totalWait = 0; | ||||
|         while (totalWait < ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT) { | ||||
|             try { | ||||
|                 Thread.sleep(singleWait); | ||||
|             } catch (InterruptedException e) { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             requestEntry = arbitraryDataFileListRequests.get(id); | ||||
|             if (requestEntry == null) | ||||
|                 return false; | ||||
|  | ||||
|             if (requestEntry.getA() == null) | ||||
|                 break; | ||||
|  | ||||
|             totalWait += singleWait; | ||||
|         } | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public void deleteFileListRequestsForSignature(byte[] signature) { | ||||
|         String signature58 = Base58.encode(signature); | ||||
|         for (Iterator<Map.Entry<Integer, Triple<String, Peer, Long>>> it = arbitraryDataFileListRequests.entrySet().iterator(); it.hasNext();) { | ||||
|             Map.Entry<Integer, Triple<String, Peer, Long>> entry = it.next(); | ||||
|             if (entry == null || entry.getKey() == null || entry.getValue() != null) { | ||||
|                 continue; | ||||
|             } | ||||
|             if (Objects.equals(entry.getValue().getA(), signature58)) { | ||||
|                 // Update requests map to reflect that we've received all chunks | ||||
|                 Triple<String, Peer, Long> newEntry = new Triple<>(null, null, entry.getValue().getC()); | ||||
|                 arbitraryDataFileListRequests.put(entry.getKey(), newEntry); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Network handlers | ||||
|  | ||||
|     public void onNetworkArbitraryDataFileListMessage(Peer peer, Message message) { | ||||
|         // Don't process if QDN is disabled | ||||
|         if (!Settings.getInstance().isQdnEnabled()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         ArbitraryDataFileListMessage arbitraryDataFileListMessage = (ArbitraryDataFileListMessage) message; | ||||
|         LOGGER.debug("Received hash list from peer {} with {} hashes", peer, arbitraryDataFileListMessage.getHashes().size()); | ||||
|  | ||||
|         // Do we have a pending request for this data? | ||||
|         Triple<String, Peer, Long> request = arbitraryDataFileListRequests.get(message.getId()); | ||||
|         if (request == null || request.getA() == null) { | ||||
|             return; | ||||
|         } | ||||
|         boolean isRelayRequest = (request.getB() != null); | ||||
|  | ||||
|         // Does this message's signature match what we're expecting? | ||||
|         byte[] signature = arbitraryDataFileListMessage.getSignature(); | ||||
|         String signature58 = Base58.encode(signature); | ||||
|         if (!request.getA().equals(signature58)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         List<byte[]> hashes = arbitraryDataFileListMessage.getHashes(); | ||||
|         if (hashes == null || hashes.isEmpty()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         ArbitraryTransactionData arbitraryTransactionData = null; | ||||
|         ArbitraryDataFileManager arbitraryDataFileManager = ArbitraryDataFileManager.getInstance(); | ||||
|  | ||||
|         // Check transaction exists and hashes are correct | ||||
|         try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|             TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); | ||||
|             if (!(transactionData instanceof ArbitraryTransactionData)) | ||||
|                 return; | ||||
|  | ||||
|             arbitraryTransactionData = (ArbitraryTransactionData) transactionData; | ||||
|  | ||||
|             // Load data file(s) | ||||
|             ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(arbitraryTransactionData.getData(), signature); | ||||
|             arbitraryDataFile.setMetadataHash(arbitraryTransactionData.getMetadataHash()); | ||||
|  | ||||
| //			// Check all hashes exist | ||||
| //			for (byte[] hash : hashes) { | ||||
| //				//LOGGER.debug("Received hash {}", Base58.encode(hash)); | ||||
| //				if (!arbitraryDataFile.containsChunk(hash)) { | ||||
| //					// Check the hash against the complete file | ||||
| //					if (!Arrays.equals(arbitraryDataFile.getHash(), hash)) { | ||||
| //						LOGGER.info("Received non-matching chunk hash {} for signature {}. This could happen if we haven't obtained the metadata file yet.", Base58.encode(hash), signature58); | ||||
| //						return; | ||||
| //					} | ||||
| //				} | ||||
| //			} | ||||
|  | ||||
|             if (!isRelayRequest || !Settings.getInstance().isRelayModeEnabled()) { | ||||
|                 // Keep track of the hashes this peer reports to have access to | ||||
|                 Long now = NTP.getTime(); | ||||
|                 for (byte[] hash : hashes) { | ||||
|                     String hash58 = Base58.encode(hash); | ||||
|                     String sig58 = Base58.encode(signature); | ||||
|                     ArbitraryDataFileManager.getInstance().arbitraryDataFileHashResponses.put(hash58, new Triple<>(peer, sig58, now)); | ||||
|                 } | ||||
|  | ||||
|                 // Go and fetch the actual data, since this isn't a relay request | ||||
|                 arbitraryDataFileManager.fetchArbitraryDataFiles(repository, peer, signature, arbitraryTransactionData, hashes); | ||||
|             } | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             LOGGER.error(String.format("Repository issue while finding arbitrary transaction data list for peer %s", peer), e); | ||||
|         } | ||||
|  | ||||
|         // Forwarding | ||||
|         if (isRelayRequest && Settings.getInstance().isRelayModeEnabled()) { | ||||
|             boolean isBlocked = (arbitraryTransactionData == null || ArbitraryDataStorageManager.getInstance().isNameBlocked(arbitraryTransactionData.getName())); | ||||
|             if (!isBlocked) { | ||||
|                 Peer requestingPeer = request.getB(); | ||||
|                 if (requestingPeer != null) { | ||||
|                     // Add each hash to our local mapping so we know who to ask later | ||||
|                     Long now = NTP.getTime(); | ||||
|                     for (byte[] hash : hashes) { | ||||
|                         String hash58 = Base58.encode(hash); | ||||
|                         ArbitraryRelayInfo relayMap = new ArbitraryRelayInfo(hash58, signature58, peer, now); | ||||
|                         ArbitraryDataFileManager.getInstance().addToRelayMap(relayMap); | ||||
|                     } | ||||
|  | ||||
|                     // Forward to requesting peer | ||||
|                     LOGGER.debug("Forwarding file list with {} hashes to requesting peer: {}", hashes.size(), requestingPeer); | ||||
|                     if (!requestingPeer.sendMessage(arbitraryDataFileListMessage)) { | ||||
|                         requestingPeer.disconnect("failed to forward arbitrary data file list"); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void onNetworkGetArbitraryDataFileListMessage(Peer peer, Message message) { | ||||
|         // Don't respond if QDN is disabled | ||||
|         if (!Settings.getInstance().isQdnEnabled()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         Controller.getInstance().stats.getArbitraryDataFileListMessageStats.requests.incrementAndGet(); | ||||
|  | ||||
|         GetArbitraryDataFileListMessage getArbitraryDataFileListMessage = (GetArbitraryDataFileListMessage) message; | ||||
|         byte[] signature = getArbitraryDataFileListMessage.getSignature(); | ||||
|         String signature58 = Base58.encode(signature); | ||||
|         List<byte[]> requestedHashes = getArbitraryDataFileListMessage.getHashes(); | ||||
|         Long now = NTP.getTime(); | ||||
|         Triple<String, Peer, Long> newEntry = new Triple<>(signature58, peer, now); | ||||
|  | ||||
|         // If we've seen this request recently, then ignore | ||||
|         if (arbitraryDataFileListRequests.putIfAbsent(message.getId(), newEntry) != null) { | ||||
|             LOGGER.debug("Ignoring hash list request from peer {} for signature {}", peer, signature58); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         LOGGER.debug("Received hash list request from peer {} for signature {}", peer, signature58); | ||||
|  | ||||
|         List<byte[]> hashes = new ArrayList<>(); | ||||
|         ArbitraryTransactionData transactionData = null; | ||||
|         boolean allChunksExist = false; | ||||
|  | ||||
|         try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|  | ||||
|             // Firstly we need to lookup this file on chain to get a list of its hashes | ||||
|             transactionData = (ArbitraryTransactionData)repository.getTransactionRepository().fromSignature(signature); | ||||
|             if (transactionData instanceof ArbitraryTransactionData) { | ||||
|  | ||||
|                 // Check if we're even allowed to serve data for this transaction | ||||
|                 if (ArbitraryDataStorageManager.getInstance().canStoreData(transactionData)) { | ||||
|  | ||||
|                     byte[] hash = transactionData.getData(); | ||||
|                     byte[] metadataHash = transactionData.getMetadataHash(); | ||||
|  | ||||
|                     // Load file(s) and add any that exist to the list of hashes | ||||
|                     ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(hash, signature); | ||||
|                     arbitraryDataFile.setMetadataHash(metadataHash); | ||||
|  | ||||
|                     // If the peer didn't supply a hash list, we need to return all hashes for this transaction | ||||
|                     if (requestedHashes == null || requestedHashes.isEmpty()) { | ||||
|                         requestedHashes = new ArrayList<>(); | ||||
|  | ||||
|                         // Add the metadata file | ||||
|                         if (arbitraryDataFile.getMetadataHash() != null) { | ||||
|                             requestedHashes.add(arbitraryDataFile.getMetadataHash()); | ||||
|                         } | ||||
|  | ||||
|                         // Add the chunk hashes | ||||
|                         if (arbitraryDataFile.getChunkHashes().size() > 0) { | ||||
|                             requestedHashes.addAll(arbitraryDataFile.getChunkHashes()); | ||||
|                         } | ||||
|                         // Add complete file if there are no hashes | ||||
|                         else { | ||||
|                             requestedHashes.add(arbitraryDataFile.getHash()); | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     // Assume all chunks exists, unless one can't be found below | ||||
|                     allChunksExist = true; | ||||
|  | ||||
|                     for (byte[] requestedHash : requestedHashes) { | ||||
|                         ArbitraryDataFileChunk chunk = ArbitraryDataFileChunk.fromHash(requestedHash, signature); | ||||
|                         if (chunk.exists()) { | ||||
|                             hashes.add(chunk.getHash()); | ||||
|                             //LOGGER.trace("Added hash {}", chunk.getHash58()); | ||||
|                         } else { | ||||
|                             LOGGER.trace("Couldn't add hash {} because it doesn't exist", chunk.getHash58()); | ||||
|                             allChunksExist = false; | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             LOGGER.error(String.format("Repository issue while fetching arbitrary file list for peer %s", peer), e); | ||||
|         } | ||||
|  | ||||
|         // We should only respond if we have at least one hash | ||||
|         if (hashes.size() > 0) { | ||||
|  | ||||
|             // We have all the chunks, so update requests map to reflect that we've sent it | ||||
|             // There is no need to keep track of the request, as we can serve all the chunks | ||||
|             if (allChunksExist) { | ||||
|                 newEntry = new Triple<>(null, null, now); | ||||
|                 arbitraryDataFileListRequests.put(message.getId(), newEntry); | ||||
|             } | ||||
|  | ||||
|             ArbitraryDataFileListMessage arbitraryDataFileListMessage = new ArbitraryDataFileListMessage(signature, hashes); | ||||
|             arbitraryDataFileListMessage.setId(message.getId()); | ||||
|             if (!peer.sendMessage(arbitraryDataFileListMessage)) { | ||||
|                 LOGGER.debug("Couldn't send list of hashes"); | ||||
|                 peer.disconnect("failed to send list of hashes"); | ||||
|                 return; | ||||
|             } | ||||
|             LOGGER.debug("Sent list of hashes (count: {})", hashes.size()); | ||||
|  | ||||
|             if (allChunksExist) { | ||||
|                 // Nothing left to do, so return to prevent any unnecessary forwarding from occurring | ||||
|                 LOGGER.debug("No need for any forwarding because file list request is fully served"); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|         } | ||||
|  | ||||
|         // We may need to forward this request on | ||||
|         boolean isBlocked = (transactionData == null || ArbitraryDataStorageManager.getInstance().isNameBlocked(transactionData.getName())); | ||||
|         if (Settings.getInstance().isRelayModeEnabled() && !isBlocked) { | ||||
|             // In relay mode - so ask our other peers if they have it | ||||
|  | ||||
|             long requestTime = getArbitraryDataFileListMessage.getRequestTime(); | ||||
|             int requestHops = getArbitraryDataFileListMessage.getRequestHops(); | ||||
|             getArbitraryDataFileListMessage.setRequestHops(++requestHops); | ||||
|             long totalRequestTime = now - requestTime; | ||||
|  | ||||
|             if (totalRequestTime < RELAY_REQUEST_MAX_DURATION) { | ||||
|                 // Relay request hasn't timed out yet, so can potentially be rebroadcast | ||||
|                 if (requestHops < RELAY_REQUEST_MAX_HOPS) { | ||||
|                     // Relay request hasn't reached the maximum number of hops yet, so can be rebroadcast | ||||
|  | ||||
|                     LOGGER.debug("Rebroadcasting hash list request from peer {} for signature {} to our other peers... totalRequestTime: {}, requestHops: {}", peer, Base58.encode(signature), totalRequestTime, requestHops); | ||||
|                     Network.getInstance().broadcast( | ||||
|                             broadcastPeer -> broadcastPeer == peer || | ||||
|                                     Objects.equals(broadcastPeer.getPeerData().getAddress().getHost(), peer.getPeerData().getAddress().getHost()) | ||||
|                                     ? null : getArbitraryDataFileListMessage); | ||||
|  | ||||
|                 } | ||||
|                 else { | ||||
|                     // This relay request has reached the maximum number of allowed hops | ||||
|                 } | ||||
|             } | ||||
|             else { | ||||
|                 // This relay request has timed out | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,509 @@ | ||||
| package org.qortal.controller.arbitrary; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.arbitrary.ArbitraryDataFile; | ||||
| import org.qortal.controller.Controller; | ||||
| import org.qortal.data.arbitrary.ArbitraryRelayInfo; | ||||
| import org.qortal.data.network.ArbitraryPeerData; | ||||
| import org.qortal.data.network.PeerData; | ||||
| import org.qortal.data.transaction.ArbitraryTransactionData; | ||||
| import org.qortal.network.Network; | ||||
| import org.qortal.network.Peer; | ||||
| import org.qortal.network.message.*; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.RepositoryManager; | ||||
| import org.qortal.settings.Settings; | ||||
| import org.qortal.utils.ArbitraryTransactionUtils; | ||||
| import org.qortal.utils.Base58; | ||||
| import org.qortal.utils.NTP; | ||||
| import org.qortal.utils.Triple; | ||||
|  | ||||
| import java.security.SecureRandom; | ||||
| import java.util.*; | ||||
| import java.util.concurrent.ExecutorService; | ||||
| import java.util.concurrent.Executors; | ||||
| import java.util.stream.Collectors; | ||||
|  | ||||
| public class ArbitraryDataFileManager extends Thread { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataFileManager.class); | ||||
|  | ||||
|     private static ArbitraryDataFileManager instance; | ||||
|     private volatile boolean isStopping = false; | ||||
|  | ||||
|  | ||||
|     /** | ||||
|      * Map to keep track of our in progress (outgoing) arbitrary data file requests | ||||
|      */ | ||||
|     private Map<String, Long> arbitraryDataFileRequests = Collections.synchronizedMap(new HashMap<>()); | ||||
|  | ||||
|     /** | ||||
|      * Map to keep track of hashes that we might need to relay | ||||
|      */ | ||||
|     public List<ArbitraryRelayInfo> arbitraryRelayMap = Collections.synchronizedList(new ArrayList<>()); | ||||
|  | ||||
|     /** | ||||
|      * Map to keep track of any arbitrary data file hash responses | ||||
|      * Key: string - the hash encoded in base58 | ||||
|      * Value: Triple<respondingPeer, signature58, timeResponded> | ||||
|      */ | ||||
|     public Map<String, Triple<Peer, String, Long>> arbitraryDataFileHashResponses = Collections.synchronizedMap(new HashMap<>()); | ||||
|  | ||||
|  | ||||
|     private ArbitraryDataFileManager() { | ||||
|     } | ||||
|  | ||||
|     public static ArbitraryDataFileManager getInstance() { | ||||
|         if (instance == null) | ||||
|             instance = new ArbitraryDataFileManager(); | ||||
|  | ||||
|         return instance; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void run() { | ||||
|         Thread.currentThread().setName("Arbitrary Data File Manager"); | ||||
|  | ||||
|         try { | ||||
|             // Use a fixed thread pool to execute the arbitrary data file requests | ||||
|             int threadCount = 10; | ||||
|             ExecutorService arbitraryDataFileRequestExecutor = Executors.newFixedThreadPool(threadCount); | ||||
|             for (int i = 0; i < threadCount; i++) { | ||||
|                 arbitraryDataFileRequestExecutor.execute(new ArbitraryDataFileRequestThread()); | ||||
|             } | ||||
|  | ||||
|             while (!isStopping) { | ||||
|                 // Nothing to do yet | ||||
|                 Thread.sleep(1000); | ||||
|             } | ||||
|         } catch (InterruptedException e) { | ||||
|             // Fall-through to exit thread... | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void shutdown() { | ||||
|         isStopping = true; | ||||
|         this.interrupt(); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public void cleanupRequestCache(Long now) { | ||||
|         if (now == null) { | ||||
|             return; | ||||
|         } | ||||
|         final long requestMinimumTimestamp = now - ArbitraryDataManager.getInstance().ARBITRARY_REQUEST_TIMEOUT; | ||||
|         arbitraryDataFileRequests.entrySet().removeIf(entry -> entry.getValue() == null || entry.getValue() < requestMinimumTimestamp); | ||||
|  | ||||
|         final long relayMinimumTimestamp = now - ArbitraryDataManager.getInstance().ARBITRARY_RELAY_TIMEOUT; | ||||
|         arbitraryRelayMap.removeIf(entry -> entry == null || entry.getTimestamp() == null || entry.getTimestamp() < relayMinimumTimestamp); | ||||
|         arbitraryDataFileHashResponses.entrySet().removeIf(entry -> entry.getValue().getC() == null || entry.getValue().getC() < relayMinimumTimestamp); | ||||
|     } | ||||
|  | ||||
|  | ||||
|  | ||||
|     // Fetch data files by hash | ||||
|  | ||||
|     public boolean fetchArbitraryDataFiles(Repository repository, | ||||
|                                            Peer peer, | ||||
|                                            byte[] signature, | ||||
|                                            ArbitraryTransactionData arbitraryTransactionData, | ||||
|                                            List<byte[]> hashes) throws DataException { | ||||
|  | ||||
|         // Load data file(s) | ||||
|         ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(arbitraryTransactionData.getData(), signature); | ||||
|         byte[] metadataHash = arbitraryTransactionData.getMetadataHash(); | ||||
|         arbitraryDataFile.setMetadataHash(metadataHash); | ||||
|         boolean receivedAtLeastOneFile = false; | ||||
|  | ||||
|         // Now fetch actual data from this peer | ||||
|         for (byte[] hash : hashes) { | ||||
|             if (isStopping) { | ||||
|                 return false; | ||||
|             } | ||||
|             String hash58 = Base58.encode(hash); | ||||
|             if (!arbitraryDataFile.chunkExists(hash)) { | ||||
|                 // Only request the file if we aren't already requesting it from someone else | ||||
|                 if (!arbitraryDataFileRequests.containsKey(Base58.encode(hash))) { | ||||
|                     LOGGER.debug("Requesting data file {} from peer {}", hash58, peer); | ||||
|                     Long startTime = NTP.getTime(); | ||||
|                     ArbitraryDataFileMessage receivedArbitraryDataFileMessage = fetchArbitraryDataFile(peer, null, signature, hash, null); | ||||
|                     Long endTime = NTP.getTime(); | ||||
|                     if (receivedArbitraryDataFileMessage != null) { | ||||
|                         LOGGER.debug("Received data file {} from peer {}. Time taken: {} ms", receivedArbitraryDataFileMessage.getArbitraryDataFile().getHash58(), peer, (endTime-startTime)); | ||||
|                         receivedAtLeastOneFile = true; | ||||
|  | ||||
|                         // Remove this hash from arbitraryDataFileHashResponses now that we have received it | ||||
|                         arbitraryDataFileHashResponses.remove(hash58); | ||||
|                     } | ||||
|                     else { | ||||
|                         LOGGER.debug("Peer {} didn't respond with data file {} for signature {}. Time taken: {} ms", peer, Base58.encode(hash), Base58.encode(signature), (endTime-startTime)); | ||||
|  | ||||
|                         // Remove this hash from arbitraryDataFileHashResponses now that we have failed to receive it | ||||
|                         arbitraryDataFileHashResponses.remove(hash58); | ||||
|  | ||||
|                         // Stop asking for files from this peer | ||||
|                         break; | ||||
|                     } | ||||
|                 } | ||||
|                 else { | ||||
|                     LOGGER.trace("Already requesting data file {} for signature {}", arbitraryDataFile, Base58.encode(signature)); | ||||
|                 } | ||||
|             } | ||||
|             else { | ||||
|                 // Remove this hash from arbitraryDataFileHashResponses because we have a local copy | ||||
|                 arbitraryDataFileHashResponses.remove(hash58); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (receivedAtLeastOneFile) { | ||||
|             // Update our lookup table to indicate that this peer holds data for this signature | ||||
|             String peerAddress = peer.getPeerData().getAddress().toString(); | ||||
|             ArbitraryPeerData arbitraryPeerData = new ArbitraryPeerData(signature, peer); | ||||
|             repository.discardChanges(); | ||||
|             if (arbitraryPeerData.isPeerAddressValid()) { | ||||
|                 LOGGER.debug("Adding arbitrary peer: {} for signature {}", peerAddress, Base58.encode(signature)); | ||||
|                 repository.getArbitraryRepository().save(arbitraryPeerData); | ||||
|                 repository.saveChanges(); | ||||
|             } | ||||
|  | ||||
|             // Invalidate the hosted transactions cache as we are now hosting something new | ||||
|             ArbitraryDataStorageManager.getInstance().invalidateHostedTransactionsCache(); | ||||
|  | ||||
|             // Check if we have all the files we need for this transaction | ||||
|             if (arbitraryDataFile.allFilesExist()) { | ||||
|  | ||||
|                 // We have all the chunks for this transaction, so we should invalidate the transaction's name's | ||||
|                 // data cache so that it is rebuilt the next time we serve it | ||||
|                 ArbitraryDataManager.getInstance().invalidateCache(arbitraryTransactionData); | ||||
|  | ||||
|                 // We may also need to broadcast to the network that we are now hosting files for this transaction, | ||||
|                 // but only if these files are in accordance with our storage policy | ||||
|                 if (ArbitraryDataStorageManager.getInstance().canStoreData(arbitraryTransactionData)) { | ||||
|                     // Use a null peer address to indicate our own | ||||
|                     Message newArbitrarySignatureMessage = new ArbitrarySignaturesMessage(null, 0, Arrays.asList(signature)); | ||||
|                     Network.getInstance().broadcast(broadcastPeer -> newArbitrarySignatureMessage); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|         } | ||||
|  | ||||
|         return receivedAtLeastOneFile; | ||||
|     } | ||||
|  | ||||
|     private ArbitraryDataFileMessage fetchArbitraryDataFile(Peer peer, Peer requestingPeer, byte[] signature, byte[] hash, Message originalMessage) throws DataException { | ||||
|         ArbitraryDataFile existingFile = ArbitraryDataFile.fromHash(hash, signature); | ||||
|         boolean fileAlreadyExists = existingFile.exists(); | ||||
|         String hash58 = Base58.encode(hash); | ||||
|         Message message = null; | ||||
|  | ||||
|         // Fetch the file if it doesn't exist locally | ||||
|         if (!fileAlreadyExists) { | ||||
|             LOGGER.debug(String.format("Fetching data file %.8s from peer %s", hash58, peer)); | ||||
|             arbitraryDataFileRequests.put(hash58, NTP.getTime()); | ||||
|             Message getArbitraryDataFileMessage = new GetArbitraryDataFileMessage(signature, hash); | ||||
|  | ||||
|             try { | ||||
|                 message = peer.getResponseWithTimeout(getArbitraryDataFileMessage, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT); | ||||
|             } catch (InterruptedException e) { | ||||
|                 // Will return below due to null message | ||||
|             } | ||||
|             arbitraryDataFileRequests.remove(hash58); | ||||
|             LOGGER.trace(String.format("Removed hash %.8s from arbitraryDataFileRequests", hash58)); | ||||
|  | ||||
|             // We may need to remove the file list request, if we have all the files for this transaction | ||||
|             this.handleFileListRequests(signature); | ||||
|  | ||||
|             if (message == null) { | ||||
|                 LOGGER.debug("Received null message from peer {}", peer); | ||||
|                 return null; | ||||
|             } | ||||
|             if (message.getType() != Message.MessageType.ARBITRARY_DATA_FILE) { | ||||
|                 LOGGER.debug("Received message with invalid type: {} from peer {}", message.getType(), peer); | ||||
|                 return null; | ||||
|             } | ||||
|         } | ||||
|         else { | ||||
|             LOGGER.debug(String.format("File hash %s already exists, so skipping the request", hash58)); | ||||
|         } | ||||
|         ArbitraryDataFileMessage arbitraryDataFileMessage = (ArbitraryDataFileMessage) message; | ||||
|  | ||||
|         // We might want to forward the request to the peer that originally requested it | ||||
|         this.handleArbitraryDataFileForwarding(requestingPeer, message, originalMessage); | ||||
|  | ||||
|         boolean isRelayRequest = (requestingPeer != null); | ||||
|         if (isRelayRequest) { | ||||
|             if (!fileAlreadyExists) { | ||||
|                 // File didn't exist locally before the request, and it's a forwarding request, so delete it | ||||
|                 LOGGER.debug("Deleting file {} because it was needed for forwarding only", Base58.encode(hash)); | ||||
|                 ArbitraryDataFile dataFile = arbitraryDataFileMessage.getArbitraryDataFile(); | ||||
|  | ||||
|                 // Keep trying to delete the data until it is deleted, or we reach 10 attempts | ||||
|                 for (int i=0; i<10; i++) { | ||||
|                     if (dataFile.delete()) { | ||||
|                         break; | ||||
|                     } | ||||
|                     try { | ||||
|                         Thread.sleep(1000L); | ||||
|                     } catch (InterruptedException e) { | ||||
|                         // Fall through to exit method | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return arbitraryDataFileMessage; | ||||
|     } | ||||
|  | ||||
|     private void handleFileListRequests(byte[] signature) { | ||||
|         try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|  | ||||
|             // Fetch the transaction data | ||||
|             ArbitraryTransactionData arbitraryTransactionData = ArbitraryTransactionUtils.fetchTransactionData(repository, signature); | ||||
|             if (arbitraryTransactionData == null) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             boolean allChunksExist = ArbitraryTransactionUtils.allChunksExist(arbitraryTransactionData); | ||||
|  | ||||
|             if (allChunksExist) { | ||||
|                 // Update requests map to reflect that we've received all chunks | ||||
|                 ArbitraryDataFileListManager.getInstance().deleteFileListRequestsForSignature(signature); | ||||
|             } | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             LOGGER.debug("Unable to handle file list requests: {}", e.getMessage()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void handleArbitraryDataFileForwarding(Peer requestingPeer, Message message, Message originalMessage) { | ||||
|         // Return if there is no originally requesting peer to forward to | ||||
|         if (requestingPeer == null) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Return if we're not in relay mode or if this request doesn't need forwarding | ||||
|         if (!Settings.getInstance().isRelayModeEnabled()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         LOGGER.debug("Received arbitrary data file - forwarding is needed"); | ||||
|  | ||||
|         // The ID needs to match that of the original request | ||||
|         message.setId(originalMessage.getId()); | ||||
|  | ||||
|         if (!requestingPeer.sendMessage(message)) { | ||||
|             LOGGER.debug("Failed to forward arbitrary data file to peer {}", requestingPeer); | ||||
|             requestingPeer.disconnect("failed to forward arbitrary data file"); | ||||
|         } | ||||
|         else { | ||||
|             LOGGER.debug("Forwarded arbitrary data file to peer {}", requestingPeer); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     // Fetch data directly from peers | ||||
|  | ||||
|     public boolean fetchDataFilesFromPeersForSignature(byte[] signature) { | ||||
|         String signature58 = Base58.encode(signature); | ||||
|         ArbitraryDataFileListManager.getInstance().addToSignatureRequests(signature58, false, true); | ||||
|  | ||||
|         // Firstly fetch peers that claim to be hosting files for this signature | ||||
|         try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|  | ||||
|             List<ArbitraryPeerData> peers = repository.getArbitraryRepository().getArbitraryPeerDataForSignature(signature); | ||||
|             if (peers == null || peers.isEmpty()) { | ||||
|                 LOGGER.debug("No peers found for signature {}", signature58); | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             LOGGER.debug("Attempting a direct peer connection for signature {}...", signature58); | ||||
|  | ||||
|             // Peers found, so pick a random one and request data from it | ||||
|             int index = new SecureRandom().nextInt(peers.size()); | ||||
|             ArbitraryPeerData arbitraryPeerData = peers.get(index); | ||||
|             String peerAddressString = arbitraryPeerData.getPeerAddress(); | ||||
|             boolean success = Network.getInstance().requestDataFromPeer(peerAddressString, signature); | ||||
|  | ||||
|             // Parse the peer address to find the host and port | ||||
|             String host = null; | ||||
|             int port = -1; | ||||
|             String[] parts = peerAddressString.split(":"); | ||||
|             if (parts.length > 1) { | ||||
|                 host = parts[0]; | ||||
|                 port = Integer.parseInt(parts[1]); | ||||
|             } | ||||
|  | ||||
|             // If unsuccessful, and using a non-standard port, try a second connection with the default listen port, | ||||
|             // since almost all nodes use that. This is a workaround to account for any ephemeral ports that may | ||||
|             // have made it into the dataset. | ||||
|             if (!success) { | ||||
|                 if (host != null && port > 0) { | ||||
|                     int defaultPort = Settings.getInstance().getDefaultListenPort(); | ||||
|                     if (port != defaultPort) { | ||||
|                         String newPeerAddressString = String.format("%s:%d", host, defaultPort); | ||||
|                         success = Network.getInstance().requestDataFromPeer(newPeerAddressString, signature); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // If _still_ unsuccessful, try matching the peer's IP address with some known peers, and then connect | ||||
|             // to each of those in turn until one succeeds. | ||||
|             if (!success) { | ||||
|                 if (host != null) { | ||||
|                     final String finalHost = host; | ||||
|                     List<PeerData> knownPeers = Network.getInstance().getAllKnownPeers().stream() | ||||
|                             .filter(knownPeerData -> knownPeerData.getAddress().getHost().equals(finalHost)) | ||||
|                             .collect(Collectors.toList()); | ||||
|                     // Loop through each match and attempt a connection | ||||
|                     for (PeerData matchingPeer : knownPeers) { | ||||
|                         String matchingPeerAddress = matchingPeer.getAddress().toString(); | ||||
|                         success = Network.getInstance().requestDataFromPeer(matchingPeerAddress, signature); | ||||
|                         if (success) { | ||||
|                             // Successfully connected, so stop making connections | ||||
|                             break; | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Keep track of the success or failure | ||||
|             arbitraryPeerData.markAsAttempted(); | ||||
|             if (success) { | ||||
|                 arbitraryPeerData.markAsRetrieved(); | ||||
|                 arbitraryPeerData.incrementSuccesses(); | ||||
|             } | ||||
|             else { | ||||
|                 arbitraryPeerData.incrementFailures(); | ||||
|             } | ||||
|             repository.discardChanges(); | ||||
|             repository.getArbitraryRepository().save(arbitraryPeerData); | ||||
|             repository.saveChanges(); | ||||
|  | ||||
|             return success; | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             LOGGER.debug("Unable to fetch peer list from repository"); | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|  | ||||
|     // Relays | ||||
|  | ||||
|     private List<ArbitraryRelayInfo> getRelayInfoListForHash(String hash58) { | ||||
|         synchronized (arbitraryRelayMap) { | ||||
|             return arbitraryRelayMap.stream() | ||||
|                     .filter(relayInfo -> Objects.equals(relayInfo.getHash58(), hash58)) | ||||
|                     .collect(Collectors.toList()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private ArbitraryRelayInfo getRandomRelayInfoEntryForHash(String hash58) { | ||||
|         LOGGER.info("Fetching random relay info for hash: {}", hash58); | ||||
|         List<ArbitraryRelayInfo> relayInfoList = this.getRelayInfoListForHash(hash58); | ||||
|         if (relayInfoList != null && !relayInfoList.isEmpty()) { | ||||
|  | ||||
|             // Pick random item | ||||
|             int index = new SecureRandom().nextInt(relayInfoList.size()); | ||||
|             LOGGER.info("Returning random relay info for hash: {} (index {})", hash58, index); | ||||
|             return relayInfoList.get(index); | ||||
|         } | ||||
|         LOGGER.info("No relay info exists for hash: {}", hash58); | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public void addToRelayMap(ArbitraryRelayInfo newEntry) { | ||||
|         if (newEntry == null || !newEntry.isValid()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Remove existing entry for this peer if it exists, to renew the timestamp | ||||
|         this.removeFromRelayMap(newEntry); | ||||
|  | ||||
|         // Re-add | ||||
|         arbitraryRelayMap.add(newEntry); | ||||
|         LOGGER.debug("Added entry to relay map: {}", newEntry); | ||||
|     } | ||||
|  | ||||
|     private void removeFromRelayMap(ArbitraryRelayInfo entry) { | ||||
|         arbitraryRelayMap.removeIf(relayInfo -> relayInfo.equals(entry)); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     // Network handlers | ||||
|  | ||||
|     public void onNetworkGetArbitraryDataFileMessage(Peer peer, Message message) { | ||||
|         // Don't respond if QDN is disabled | ||||
|         if (!Settings.getInstance().isQdnEnabled()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         GetArbitraryDataFileMessage getArbitraryDataFileMessage = (GetArbitraryDataFileMessage) message; | ||||
|         byte[] hash = getArbitraryDataFileMessage.getHash(); | ||||
|         String hash58 = Base58.encode(hash); | ||||
|         byte[] signature = getArbitraryDataFileMessage.getSignature(); | ||||
|         Controller.getInstance().stats.getArbitraryDataFileMessageStats.requests.incrementAndGet(); | ||||
|  | ||||
|         LOGGER.debug("Received GetArbitraryDataFileMessage from peer {} for hash {}", peer, Base58.encode(hash)); | ||||
|  | ||||
|         try { | ||||
|             ArbitraryDataFile arbitraryDataFile = ArbitraryDataFile.fromHash(hash, signature); | ||||
|             ArbitraryRelayInfo relayInfo = this.getRandomRelayInfoEntryForHash(hash58); | ||||
|  | ||||
|             if (arbitraryDataFile.exists()) { | ||||
|                 LOGGER.trace("Hash {} exists", hash58); | ||||
|  | ||||
|                 // We can serve the file directly as we already have it | ||||
|                 ArbitraryDataFileMessage arbitraryDataFileMessage = new ArbitraryDataFileMessage(signature, arbitraryDataFile); | ||||
|                 arbitraryDataFileMessage.setId(message.getId()); | ||||
|                 if (!peer.sendMessage(arbitraryDataFileMessage)) { | ||||
|                     LOGGER.debug("Couldn't sent file"); | ||||
|                     peer.disconnect("failed to send file"); | ||||
|                 } | ||||
|                 LOGGER.debug("Sent file {}", arbitraryDataFile); | ||||
|             } | ||||
|             else if (relayInfo != null) { | ||||
|                 LOGGER.debug("We have relay info for hash {}", Base58.encode(hash)); | ||||
|                 // We need to ask this peer for the file | ||||
|                 Peer peerToAsk = relayInfo.getPeer(); | ||||
|                 if (peerToAsk != null) { | ||||
|  | ||||
|                     // Forward the message to this peer | ||||
|                     LOGGER.debug("Asking peer {} for hash {}", peerToAsk, hash58); | ||||
|                     this.fetchArbitraryDataFile(peerToAsk, peer, signature, hash, message); | ||||
|                 } | ||||
|                 else { | ||||
|                     LOGGER.debug("Peer {} not found in relay info", peer); | ||||
|                 } | ||||
|             } | ||||
|             else { | ||||
|                 LOGGER.debug("Hash {} doesn't exist and we don't have relay info", hash58); | ||||
|  | ||||
|                 // We don't have this file | ||||
|                 Controller.getInstance().stats.getArbitraryDataFileMessageStats.unknownFiles.getAndIncrement(); | ||||
|  | ||||
|                 // Send valid, yet unexpected message type in response, so peer's synchronizer doesn't have to wait for timeout | ||||
|                 LOGGER.debug(String.format("Sending 'file unknown' response to peer %s for GET_FILE request for unknown file %s", peer, arbitraryDataFile)); | ||||
|  | ||||
|                 // We'll send empty block summaries message as it's very short | ||||
|                 // TODO: use a different message type here | ||||
|                 Message fileUnknownMessage = new BlockSummariesMessage(Collections.emptyList()); | ||||
|                 fileUnknownMessage.setId(message.getId()); | ||||
|                 if (!peer.sendMessage(fileUnknownMessage)) { | ||||
|                     LOGGER.debug("Couldn't sent file-unknown response"); | ||||
|                     peer.disconnect("failed to send file-unknown response"); | ||||
|                 } | ||||
|                 else { | ||||
|                     LOGGER.debug("Sent file-unknown response for file {}", arbitraryDataFile); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         catch (DataException e) { | ||||
|             LOGGER.debug("Unable to handle request for arbitrary data file: {}", hash58); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,121 @@ | ||||
| package org.qortal.controller.arbitrary; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.controller.Controller; | ||||
| import org.qortal.data.transaction.ArbitraryTransactionData; | ||||
| import org.qortal.network.Peer; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.RepositoryManager; | ||||
| import org.qortal.utils.ArbitraryTransactionUtils; | ||||
| import org.qortal.utils.Base58; | ||||
| import org.qortal.utils.NTP; | ||||
| import org.qortal.utils.Triple; | ||||
|  | ||||
| import java.util.Arrays; | ||||
| import java.util.Iterator; | ||||
| import java.util.Map; | ||||
|  | ||||
| public class ArbitraryDataFileRequestThread implements Runnable { | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataFileRequestThread.class); | ||||
|  | ||||
|     public ArbitraryDataFileRequestThread() { | ||||
|  | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void run() { | ||||
|         Thread.currentThread().setName("Arbitrary Data File Request Thread"); | ||||
|  | ||||
|         try { | ||||
|             while (!Controller.isStopping()) { | ||||
|                 Thread.sleep(1000); | ||||
|  | ||||
|                 Long now = NTP.getTime(); | ||||
|                 this.processFileHashes(now); | ||||
|             } | ||||
|         } catch (InterruptedException e) { | ||||
|             // Fall-through to exit thread... | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void processFileHashes(Long now) { | ||||
|         if (Controller.isStopping()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         try (final Repository repository = RepositoryManager.getRepository()) { | ||||
|             ArbitraryDataFileManager arbitraryDataFileManager = ArbitraryDataFileManager.getInstance(); | ||||
|  | ||||
|             ArbitraryTransactionData arbitraryTransactionData = null; | ||||
|             byte[] signature = null; | ||||
|             byte[] hash = null; | ||||
|             Peer peer = null; | ||||
|             boolean shouldProcess = false; | ||||
|  | ||||
|             synchronized (arbitraryDataFileManager.arbitraryDataFileHashResponses) { | ||||
|                 Iterator iterator = arbitraryDataFileManager.arbitraryDataFileHashResponses.entrySet().iterator(); | ||||
|                 while (iterator.hasNext()) { | ||||
|                     if (Controller.isStopping()) { | ||||
|                         return; | ||||
|                     } | ||||
|  | ||||
|                     Map.Entry entry = (Map.Entry) iterator.next(); | ||||
|                     if (entry == null || entry.getKey() == null || entry.getValue() == null) { | ||||
|                         iterator.remove(); | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     String hash58 = (String) entry.getKey(); | ||||
|                     Triple<Peer, String, Long> value = (Triple<Peer, String, Long>) entry.getValue(); | ||||
|                     if (value == null) { | ||||
|                         iterator.remove(); | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     peer = value.getA(); | ||||
|                     String signature58 = value.getB(); | ||||
|                     Long timestamp = value.getC(); | ||||
|  | ||||
|                     if (now - timestamp >= ArbitraryDataManager.ARBITRARY_RELAY_TIMEOUT || signature58 == null || peer == null) { | ||||
|                         // Ignore - to be deleted | ||||
|                         iterator.remove(); | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     hash = Base58.decode(hash58); | ||||
|                     signature = Base58.decode(signature58); | ||||
|  | ||||
|                     // We want to process this file | ||||
|                     shouldProcess = true; | ||||
|                     iterator.remove(); | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (!shouldProcess) { | ||||
|                 // Nothing to do | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // Fetch the transaction data | ||||
|             arbitraryTransactionData = ArbitraryTransactionUtils.fetchTransactionData(repository, signature); | ||||
|             if (arbitraryTransactionData == null) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (signature == null || hash == null || peer == null || arbitraryTransactionData == null) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             String hash58 = Base58.encode(hash); | ||||
|             LOGGER.debug("Fetching file {} from peer {} via request thread...", hash58, peer); | ||||
|             arbitraryDataFileManager.fetchArbitraryDataFiles(repository, peer, signature, arbitraryTransactionData, Arrays.asList(hash)); | ||||
|  | ||||
|         } catch (DataException e) { | ||||
|             LOGGER.debug("Unable to process file hashes: {}", e.getMessage()); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,458 @@ | ||||
| package org.qortal.controller.arbitrary; | ||||
|  | ||||
| import java.io.IOException; | ||||
| import java.util.*; | ||||
| import java.util.stream.Collectors; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; | ||||
| import org.qortal.arbitrary.ArbitraryDataFile; | ||||
| import org.qortal.arbitrary.ArbitraryDataResource; | ||||
| import org.qortal.arbitrary.misc.Service; | ||||
| import org.qortal.controller.Controller; | ||||
| import org.qortal.data.network.ArbitraryPeerData; | ||||
| 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.network.message.*; | ||||
| 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.TransactionType; | ||||
| import org.qortal.utils.ArbitraryTransactionUtils; | ||||
| import org.qortal.utils.Base58; | ||||
| import org.qortal.utils.NTP; | ||||
|  | ||||
| public class ArbitraryDataManager extends Thread { | ||||
|  | ||||
| 	private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataManager.class); | ||||
| 	private static final List<TransactionType> ARBITRARY_TX_TYPE = Arrays.asList(TransactionType.ARBITRARY); | ||||
|  | ||||
| 	/** Difficulty (leading zero bits) used in arbitrary data transactions | ||||
| 	 * Set here so that it can be more easily reduced when running unit tests */ | ||||
| 	private int powDifficulty = 14; // Must not be final, as unit tests need to reduce this value | ||||
|  | ||||
| 	/** Request timeout when transferring arbitrary data */ | ||||
| 	public static final long ARBITRARY_REQUEST_TIMEOUT = 10 * 1000L; // ms | ||||
|  | ||||
| 	/** Maximum time to hold information about an in-progress relay */ | ||||
| 	public static final long ARBITRARY_RELAY_TIMEOUT = 60 * 1000L; // ms | ||||
|  | ||||
| 	/** Maximum number of hops that an arbitrary signatures request is allowed to make */ | ||||
| 	private static int ARBITRARY_SIGNATURES_REQUEST_MAX_HOPS = 3; | ||||
|  | ||||
| 	private static ArbitraryDataManager instance; | ||||
| 	private final Object peerDataLock = new Object(); | ||||
|  | ||||
| 	private volatile boolean isStopping = false; | ||||
|  | ||||
| 	/** | ||||
| 	 * Map to keep track of cached arbitrary transaction resources. | ||||
| 	 * When an item is present in this list with a timestamp in the future, we won't invalidate | ||||
| 	 * its cache when serving that data. This reduces the amount of database lookups that are needed. | ||||
| 	 */ | ||||
| 	private Map<String, Long> arbitraryDataCachedResources = Collections.synchronizedMap(new HashMap<>()); | ||||
|  | ||||
| 	/** | ||||
| 	 * The amount of time to cache a data resource before it is invalidated | ||||
| 	 */ | ||||
| 	private static long ARBITRARY_DATA_CACHE_TIMEOUT = 60 * 60 * 1000L; // 60 minutes | ||||
|  | ||||
|  | ||||
|  | ||||
| 	private ArbitraryDataManager() { | ||||
| 	} | ||||
|  | ||||
| 	public static ArbitraryDataManager getInstance() { | ||||
| 		if (instance == null) | ||||
| 			instance = new ArbitraryDataManager(); | ||||
|  | ||||
| 		return instance; | ||||
| 	} | ||||
|  | ||||
| 	@Override | ||||
| 	public void run() { | ||||
| 		Thread.currentThread().setName("Arbitrary Data Manager"); | ||||
|  | ||||
| 		try { | ||||
| 			while (!isStopping) { | ||||
| 				Thread.sleep(2000); | ||||
|  | ||||
| 				// Don't run if QDN is disabled | ||||
| 				if (!Settings.getInstance().isQdnEnabled()) { | ||||
| 					Thread.sleep(60 * 60 * 1000L); | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				List<Peer> peers = Network.getInstance().getHandshakedPeers(); | ||||
|  | ||||
| 				// Disregard peers that have "misbehaved" recently | ||||
| 				peers.removeIf(Controller.hasMisbehaved); | ||||
|  | ||||
| 				// Don't fetch data if we don't have enough up-to-date peers | ||||
| 				if (peers.size() < Settings.getInstance().getMinBlockchainPeers()) { | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				// Fetch data according to storage policy | ||||
| 				switch (Settings.getInstance().getStoragePolicy()) { | ||||
| 					case FOLLOWED: | ||||
| 					case FOLLOWED_OR_VIEWED: | ||||
| 						this.processNames(); | ||||
| 						break; | ||||
|  | ||||
| 					case ALL: | ||||
| 						this.processAll(); | ||||
|  | ||||
| 					case NONE: | ||||
| 					case VIEWED: | ||||
| 					default: | ||||
| 						// Nothing to fetch in advance | ||||
| 						Thread.sleep(60000); | ||||
| 						break; | ||||
| 				} | ||||
| 			} | ||||
| 		} catch (InterruptedException e) { | ||||
| 			// Fall-through to exit thread... | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public void shutdown() { | ||||
| 		isStopping = true; | ||||
| 		this.interrupt(); | ||||
| 	} | ||||
|  | ||||
| 	private void processNames() { | ||||
| 		// Fetch latest list of followed names | ||||
| 		List<String> followedNames = ResourceListManager.getInstance().getStringsInList("followedNames"); | ||||
| 		if (followedNames == null || followedNames.isEmpty()) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		// Loop through the names in the list and fetch transactions for each | ||||
| 		for (String name : followedNames) { | ||||
| 			this.fetchAndProcessTransactions(name); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private void processAll() { | ||||
| 		this.fetchAndProcessTransactions(null); | ||||
| 	} | ||||
|  | ||||
| 	private void fetchAndProcessTransactions(String name) { | ||||
| 		ArbitraryDataStorageManager storageManager = ArbitraryDataStorageManager.getInstance(); | ||||
|  | ||||
| 		// Paginate queries when fetching arbitrary transactions | ||||
| 		final int limit = 100; | ||||
| 		int offset = 0; | ||||
|  | ||||
| 		while (!isStopping) { | ||||
|  | ||||
| 			// Any arbitrary transactions we want to fetch data for? | ||||
| 			try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 				List<byte[]> signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, null, ARBITRARY_TX_TYPE, null, name, null, ConfirmationStatus.BOTH, limit, offset, true); | ||||
| 				// LOGGER.trace("Found {} arbitrary transactions at offset: {}, limit: {}", signatures.size(), offset, limit); | ||||
| 				if (signatures == null || signatures.isEmpty()) { | ||||
| 					offset = 0; | ||||
| 					break; | ||||
| 				} | ||||
| 				offset += limit; | ||||
|  | ||||
| 				// Loop through signatures and remove ones we don't need to process | ||||
| 				Iterator iterator = signatures.iterator(); | ||||
| 				while (iterator.hasNext()) { | ||||
| 					byte[] signature = (byte[]) iterator.next(); | ||||
|  | ||||
| 					ArbitraryTransaction arbitraryTransaction = fetchTransaction(repository, signature); | ||||
| 					if (arbitraryTransaction == null) { | ||||
| 						// Best not to process this one | ||||
| 						iterator.remove(); | ||||
| 						continue; | ||||
| 					} | ||||
| 					ArbitraryTransactionData arbitraryTransactionData = (ArbitraryTransactionData) arbitraryTransaction.getTransactionData(); | ||||
|  | ||||
| 					// Skip transactions that we don't need to proactively store data for | ||||
| 					if (!storageManager.shouldPreFetchData(repository, arbitraryTransactionData)) { | ||||
| 						iterator.remove(); | ||||
| 						continue; | ||||
| 					} | ||||
|  | ||||
| 					// Remove transactions that we already have local data for | ||||
| 					if (hasLocalData(arbitraryTransaction)) { | ||||
| 						iterator.remove(); | ||||
| 						continue; | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				if (signatures.isEmpty()) { | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				// Pick one at random | ||||
| 				final int index = new Random().nextInt(signatures.size()); | ||||
| 				byte[] signature = signatures.get(index); | ||||
|  | ||||
| 				if (signature == null) { | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				// Check to see if we have had a more recent PUT | ||||
| 				ArbitraryTransactionData arbitraryTransactionData = ArbitraryTransactionUtils.fetchTransactionData(repository, signature); | ||||
| 				boolean hasMoreRecentPutTransaction = ArbitraryTransactionUtils.hasMoreRecentPutTransaction(repository, arbitraryTransactionData); | ||||
| 				if (hasMoreRecentPutTransaction) { | ||||
| 					// There is a more recent PUT transaction than the one we are currently processing. | ||||
| 					// When a PUT is issued, it replaces any layers that would have been there before. | ||||
| 					// Therefore any data relating to this older transaction is no longer needed and we | ||||
| 					// shouldn't fetch it from the network. | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				// Ask our connected peers if they have files for this signature | ||||
| 				// This process automatically then fetches the files themselves if a peer is found | ||||
| 				fetchData(arbitraryTransactionData); | ||||
|  | ||||
| 			} catch (DataException e) { | ||||
| 				LOGGER.error("Repository issue when fetching arbitrary transaction data", e); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private ArbitraryTransaction fetchTransaction(final Repository repository, byte[] signature) { | ||||
| 		try { | ||||
| 			TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); | ||||
| 			if (!(transactionData instanceof ArbitraryTransactionData)) | ||||
| 				return null; | ||||
|  | ||||
| 			return new ArbitraryTransaction(repository, transactionData); | ||||
|  | ||||
| 		} catch (DataException e) { | ||||
| 			return null; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private boolean hasLocalData(ArbitraryTransaction arbitraryTransaction) { | ||||
| 		try { | ||||
| 			return arbitraryTransaction.isDataLocal(); | ||||
|  | ||||
| 		} catch (DataException e) { | ||||
| 			LOGGER.error("Repository issue when checking arbitrary transaction's data is local", e); | ||||
| 			return true; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	// Entrypoint to request new data from peers | ||||
| 	public boolean fetchData(ArbitraryTransactionData arbitraryTransactionData) { | ||||
| 		return ArbitraryDataFileListManager.getInstance().fetchArbitraryDataFileList(arbitraryTransactionData); | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	// Useful methods used by other parts of the app | ||||
|  | ||||
| 	public boolean isSignatureRateLimited(byte[] signature) { | ||||
| 		return ArbitraryDataFileListManager.getInstance().isSignatureRateLimited(signature); | ||||
| 	} | ||||
|  | ||||
| 	public long lastRequestForSignature(byte[] signature) { | ||||
| 		return ArbitraryDataFileListManager.getInstance().lastRequestForSignature(signature); | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	// Arbitrary data resource cache | ||||
|  | ||||
| 	public void cleanupRequestCache(Long now) { | ||||
| 		if (now == null) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		// Cleanup file list request caches | ||||
| 		ArbitraryDataFileListManager.getInstance().cleanupRequestCache(now); | ||||
|  | ||||
| 		// Cleanup file request caches | ||||
| 		ArbitraryDataFileManager.getInstance().cleanupRequestCache(now); | ||||
| 	} | ||||
|  | ||||
| 	public boolean isResourceCached(ArbitraryDataResource resource) { | ||||
| 		if (resource == null) { | ||||
| 			return false; | ||||
| 		} | ||||
| 		String key = resource.getUniqueKey(); | ||||
|  | ||||
| 		// We don't have an entry for this resource ID, it is not cached | ||||
| 		if (this.arbitraryDataCachedResources == null) { | ||||
| 			return false; | ||||
| 		} | ||||
| 		if (!this.arbitraryDataCachedResources.containsKey(key)) { | ||||
| 			return false; | ||||
| 		} | ||||
| 		Long timestamp = this.arbitraryDataCachedResources.get(key); | ||||
| 		if (timestamp == null) { | ||||
| 			return false; | ||||
| 		} | ||||
|  | ||||
| 		// If the timestamp has reached the timeout, we should remove it from the cache | ||||
| 		long now = NTP.getTime(); | ||||
| 		if (now > timestamp) { | ||||
| 			this.arbitraryDataCachedResources.remove(key); | ||||
| 			return false; | ||||
| 		} | ||||
|  | ||||
| 		// Current time hasn't reached the timeout, so treat it as cached | ||||
| 		return true; | ||||
| 	} | ||||
|  | ||||
| 	public void addResourceToCache(ArbitraryDataResource resource) { | ||||
| 		if (resource == null) { | ||||
| 			return; | ||||
| 		} | ||||
| 		String key = resource.getUniqueKey(); | ||||
|  | ||||
| 		// Just in case | ||||
| 		if (this.arbitraryDataCachedResources == null) { | ||||
| 			this.arbitraryDataCachedResources = new HashMap<>(); | ||||
| 		} | ||||
|  | ||||
| 		Long now = NTP.getTime(); | ||||
| 		if (now == null) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		// Set the timestamp to now + the timeout | ||||
| 		Long timestamp = NTP.getTime() + ARBITRARY_DATA_CACHE_TIMEOUT; | ||||
| 		this.arbitraryDataCachedResources.put(key, timestamp); | ||||
| 	} | ||||
|  | ||||
| 	public void invalidateCache(ArbitraryTransactionData arbitraryTransactionData) { | ||||
| 		String signature58 = Base58.encode(arbitraryTransactionData.getSignature()); | ||||
|  | ||||
| 		if (arbitraryTransactionData.getName() != null) { | ||||
| 			String resourceId = arbitraryTransactionData.getName().toLowerCase(); | ||||
| 			Service service = arbitraryTransactionData.getService(); | ||||
| 			String identifier = arbitraryTransactionData.getIdentifier(); | ||||
|  | ||||
| 			ArbitraryDataResource resource = | ||||
| 					new ArbitraryDataResource(resourceId, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); | ||||
| 			String key = resource.getUniqueKey(); | ||||
| 			LOGGER.trace("Clearing cache for {}...", resource); | ||||
|  | ||||
| 			if (this.arbitraryDataCachedResources.containsKey(key)) { | ||||
| 				this.arbitraryDataCachedResources.remove(key); | ||||
| 			} | ||||
|  | ||||
| 			// Also remove from the failed builds queue in case it previously failed due to missing chunks | ||||
| 			ArbitraryDataBuildManager buildManager = ArbitraryDataBuildManager.getInstance(); | ||||
| 			if (buildManager.arbitraryDataFailedBuilds.containsKey(key)) { | ||||
| 				buildManager.arbitraryDataFailedBuilds.remove(key); | ||||
| 			} | ||||
|  | ||||
| 			// Remove from the signature requests list now that we have all files for this signature | ||||
| 			ArbitraryDataFileListManager.getInstance().removeFromSignatureRequests(signature58); | ||||
|  | ||||
| 			// Delete cached files themselves | ||||
| 			try { | ||||
| 				resource.deleteCache(); | ||||
| 			} catch (IOException e) { | ||||
| 				LOGGER.info("Unable to delete cache for resource {}: {}", resource, e.getMessage()); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	// Broadcast list of hosted signatures | ||||
|  | ||||
| 	public void broadcastHostedSignatureList() { | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			List<ArbitraryTransactionData> hostedTransactions = ArbitraryDataStorageManager.getInstance().listAllHostedTransactions(repository, null, null); | ||||
| 			List<byte[]> hostedSignatures = hostedTransactions.stream().map(ArbitraryTransactionData::getSignature).collect(Collectors.toList()); | ||||
| 			if (!hostedSignatures.isEmpty()) { | ||||
| 				// Broadcast the list, using null to represent our peer address | ||||
| 				LOGGER.info("Broadcasting list of hosted signatures..."); | ||||
| 				Message arbitrarySignatureMessage = new ArbitrarySignaturesMessage(null, 0, hostedSignatures); | ||||
| 				Network.getInstance().broadcast(broadcastPeer -> arbitrarySignatureMessage); | ||||
| 			} | ||||
| 		} catch (DataException e) { | ||||
| 			LOGGER.error("Repository issue when fetching arbitrary transaction data for broadcast", e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	// Handle incoming arbitrary signatures messages | ||||
|  | ||||
| 	public void onNetworkArbitrarySignaturesMessage(Peer peer, Message message) { | ||||
| 		// Don't process if QDN is disabled | ||||
| 		if (!Settings.getInstance().isQdnEnabled()) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		LOGGER.debug("Received arbitrary signature list from peer {}", peer); | ||||
|  | ||||
| 		ArbitrarySignaturesMessage arbitrarySignaturesMessage = (ArbitrarySignaturesMessage) message; | ||||
| 		List<byte[]> signatures = arbitrarySignaturesMessage.getSignatures(); | ||||
|  | ||||
| 		String peerAddress = peer.getPeerData().getAddress().toString(); | ||||
| 		if (arbitrarySignaturesMessage.getPeerAddress() != null && !arbitrarySignaturesMessage.getPeerAddress().isEmpty()) { | ||||
| 			// This message is about a different peer than the one that sent it | ||||
| 			peerAddress = arbitrarySignaturesMessage.getPeerAddress(); | ||||
| 		} | ||||
|  | ||||
| 		boolean containsNewEntry = false; | ||||
|  | ||||
| 		// Synchronize peer data lookups to make this process thread safe. Otherwise we could broadcast | ||||
| 		// the same data multiple times, due to more than one thread processing the same message from different peers | ||||
| 		synchronized (this.peerDataLock) { | ||||
| 			try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 				for (byte[] signature : signatures) { | ||||
|  | ||||
| 					// Check if a record already exists for this hash/host combination | ||||
| 					// The port is not checked here - only the host/ip - in order to avoid duplicates | ||||
| 					// from filling up the db due to dynamic/ephemeral ports | ||||
| 					ArbitraryPeerData existingEntry = repository.getArbitraryRepository() | ||||
| 							.getArbitraryPeerDataForSignatureAndHost(signature, peer.getPeerData().getAddress().getHost()); | ||||
|  | ||||
| 					if (existingEntry == null) { | ||||
| 						// We haven't got a record of this mapping yet, so add it | ||||
| 						ArbitraryPeerData arbitraryPeerData = new ArbitraryPeerData(signature, peerAddress); | ||||
| 						repository.discardChanges(); | ||||
| 						if (arbitraryPeerData.isPeerAddressValid()) { | ||||
| 							LOGGER.debug("Adding arbitrary peer: {} for signature {}", peerAddress, Base58.encode(signature)); | ||||
| 							repository.getArbitraryRepository().save(arbitraryPeerData); | ||||
| 							repository.saveChanges(); | ||||
|  | ||||
| 							// Remember that this data is new, so that it can be rebroadcast later | ||||
| 							containsNewEntry = true; | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				// If at least one signature in this batch was new to us, we should rebroadcast the message to the | ||||
| 				// network in case some peers haven't received it yet | ||||
| 				if (containsNewEntry) { | ||||
| 					int requestHops = arbitrarySignaturesMessage.getRequestHops(); | ||||
| 					arbitrarySignaturesMessage.setRequestHops(++requestHops); | ||||
| 					if (requestHops < ARBITRARY_SIGNATURES_REQUEST_MAX_HOPS) { | ||||
| 						LOGGER.debug("Rebroadcasting arbitrary signature list for peer {}. requestHops: {}", peerAddress, requestHops); | ||||
| 						Network.getInstance().broadcast(broadcastPeer -> broadcastPeer == peer ? null : arbitrarySignaturesMessage); | ||||
| 					} | ||||
| 				} else { | ||||
| 					// Don't rebroadcast as otherwise we could get into a loop | ||||
| 				} | ||||
|  | ||||
| 				// If anything needed saving, it would already have called saveChanges() above | ||||
| 				repository.discardChanges(); | ||||
| 			} catch (DataException e) { | ||||
| 				LOGGER.error(String.format("Repository issue while processing arbitrary transaction signature list from peer %s", peer), e); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
|  | ||||
| 	public int getPowDifficulty() { | ||||
| 		return this.powDifficulty; | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,86 @@ | ||||
| package org.qortal.controller.arbitrary; | ||||
|  | ||||
| import org.qortal.arbitrary.ArbitraryDataResource; | ||||
| import org.qortal.utils.NTP; | ||||
|  | ||||
| import java.util.*; | ||||
|  | ||||
| public class ArbitraryDataRenderManager extends Thread { | ||||
|  | ||||
|     private static ArbitraryDataRenderManager instance; | ||||
|     private volatile boolean isStopping = false; | ||||
|  | ||||
|     /** | ||||
|      * Map to keep track of authorized resources for rendering. | ||||
|      * Keyed by resource ID, with the authorization time as the value. | ||||
|      */ | ||||
|     private Map<String, Long> authorizedResources = Collections.synchronizedMap(new HashMap<>()); | ||||
|  | ||||
|     private static long AUTHORIZATION_TIMEOUT = 60 * 60 * 1000L; // 1 hour | ||||
|  | ||||
|  | ||||
|     public ArbitraryDataRenderManager() { | ||||
|  | ||||
|     } | ||||
|  | ||||
|     public static ArbitraryDataRenderManager getInstance() { | ||||
|         if (instance == null) | ||||
|             instance = new ArbitraryDataRenderManager(); | ||||
|  | ||||
|         return instance; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void run() { | ||||
|         Thread.currentThread().setName("Arbitrary Data Manager"); | ||||
|  | ||||
|         try { | ||||
|             while (!isStopping) { | ||||
|                 Thread.sleep(60000); | ||||
|  | ||||
|                 Long now = NTP.getTime(); | ||||
|                 this.cleanup(now); | ||||
|             } | ||||
|         } catch (InterruptedException e) { | ||||
|             // Fall-through to exit thread... | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void shutdown() { | ||||
|         isStopping = true; | ||||
|         this.interrupt(); | ||||
|     } | ||||
|  | ||||
|     public void cleanup(Long now) { | ||||
|         if (now == null) { | ||||
|             return; | ||||
|         } | ||||
|         final long minimumTimestamp = now - AUTHORIZATION_TIMEOUT; | ||||
|         this.authorizedResources.entrySet().removeIf(entry -> entry.getValue() == null || entry.getValue() < minimumTimestamp); | ||||
|     } | ||||
|  | ||||
|     public boolean isAuthorized(ArbitraryDataResource resource) { | ||||
|         ArbitraryDataResource broadResource = new ArbitraryDataResource(resource.getResourceId(), null, null, null); | ||||
|  | ||||
|         for (String authorizedResourceKey : this.authorizedResources.keySet()) { | ||||
|             if (authorizedResourceKey != null && resource != null) { | ||||
|                 // Check for exact match | ||||
|                 if (Objects.equals(authorizedResourceKey, resource.getUniqueKey())) { | ||||
|                     return true; | ||||
|                 } | ||||
|                 // Check for a broad authorization (which applies to all services and identifiers under an authorized name) | ||||
|                 if (Objects.equals(authorizedResourceKey, broadResource.getUniqueKey())) { | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public void addToAuthorizedResources(ArbitraryDataResource resource) { | ||||
|         if (!this.isAuthorized(resource)) { | ||||
|             this.authorizedResources.put(resource.getUniqueKey(), NTP.getTime()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,500 @@ | ||||
| package org.qortal.controller.arbitrary; | ||||
|  | ||||
| import org.apache.commons.io.FileUtils; | ||||
| 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 java.io.IOException; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.Path; | ||||
| import java.nio.file.Paths; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Comparator; | ||||
| import java.util.List; | ||||
| import java.util.Objects; | ||||
| import java.util.stream.Collectors; | ||||
|  | ||||
| public class ArbitraryDataStorageManager extends Thread { | ||||
|  | ||||
|     public enum StoragePolicy { | ||||
|         FOLLOWED_OR_VIEWED, | ||||
|         FOLLOWED, | ||||
|         VIEWED, | ||||
|         ALL, | ||||
|         NONE | ||||
|     } | ||||
|  | ||||
|     private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataStorageManager.class); | ||||
|  | ||||
|     private static ArbitraryDataStorageManager instance; | ||||
|     private volatile boolean isStopping = false; | ||||
|  | ||||
|     private Long storageCapacity = null; | ||||
|     private long totalDirectorySize = 0L; | ||||
|     private long lastDirectorySizeCheck = 0; | ||||
|  | ||||
|     private List<ArbitraryTransactionData> hostedTransactions; | ||||
|  | ||||
|     private static final long DIRECTORY_SIZE_CHECK_INTERVAL = 10 * 60 * 1000L; // 10 minutes | ||||
|  | ||||
|     /** Treat storage as full at 90% usage, to reduce risk of going over the limit. | ||||
|      * This is necessary because we don't calculate total storage values before every write. | ||||
|      * It also helps avoid a fetch/delete loop, as we will stop fetching before the hard limit. | ||||
|      * This must be lower than DELETION_THRESHOLD. */ | ||||
|     private static final double STORAGE_FULL_THRESHOLD = 0.90f; // 90% | ||||
|  | ||||
|     /** Start deleting files once we reach 98% usage. | ||||
|      * 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% | ||||
|  | ||||
|     public ArbitraryDataStorageManager() { | ||||
|     } | ||||
|  | ||||
|     public static ArbitraryDataStorageManager getInstance() { | ||||
|         if (instance == null) | ||||
|             instance = new ArbitraryDataStorageManager(); | ||||
|  | ||||
|         return instance; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void run() { | ||||
|         Thread.currentThread().setName("Arbitrary Data Storage Manager"); | ||||
|         try { | ||||
|             while (!isStopping) { | ||||
|                 Thread.sleep(1000); | ||||
|  | ||||
|                 // Don't run if QDN is disabled | ||||
|                 if (!Settings.getInstance().isQdnEnabled()) { | ||||
|                     Thread.sleep(60 * 60 * 1000L); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 Long now = NTP.getTime(); | ||||
|                 if (now == null) { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 // Check the total directory size if we haven't in a while | ||||
|                 if (this.shouldCalculateDirectorySize(now)) { | ||||
|                     this.calculateDirectorySize(now); | ||||
|                 } | ||||
|  | ||||
|                 Thread.sleep(59000); | ||||
|             } | ||||
|         } catch (InterruptedException e) { | ||||
|             // Fall-through to exit thread... | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void shutdown() { | ||||
|         isStopping = true; | ||||
|         this.interrupt(); | ||||
|         instance = null; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Check if data relating to a transaction is allowed to | ||||
|      * exist on this node, therefore making it a mirror for this data. | ||||
|      * | ||||
|      * @param arbitraryTransactionData - the transaction | ||||
|      * @return boolean - whether to prefetch or not | ||||
|      */ | ||||
|     public boolean canStoreData(ArbitraryTransactionData arbitraryTransactionData) { | ||||
|         String name = arbitraryTransactionData.getName(); | ||||
|  | ||||
|         // We already have RAW_DATA on chain, so we only need to store data associated with hashes | ||||
|         if (arbitraryTransactionData.getDataType() != ArbitraryTransactionData.DataType.DATA_HASH) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // Don't store data unless it's an allowed type (public/private) | ||||
|         if (!this.isDataTypeAllowed(arbitraryTransactionData)) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // Don't check for storage limits here, as it can cause the cleanup manager to delete existing data | ||||
|  | ||||
|         // Check if our storage policy and and lists allow us to host data for this name | ||||
|         switch (Settings.getInstance().getStoragePolicy()) { | ||||
|             case FOLLOWED_OR_VIEWED: | ||||
|             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); | ||||
|  | ||||
|             case FOLLOWED: | ||||
|                 // If the policy is for followed data only, we have to be following it | ||||
|                 return this.isFollowingName(name); | ||||
|  | ||||
|                 // For NONE or all else, we shouldn't host this data | ||||
|             case NONE: | ||||
|             default: | ||||
|                 return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Check if data relating to a transaction should be downloaded | ||||
|      * automatically, making this node a mirror for that data. | ||||
|      * | ||||
|      * @param arbitraryTransactionData - the transaction | ||||
|      * @return boolean - whether to prefetch or not | ||||
|      */ | ||||
|     public boolean shouldPreFetchData(Repository repository, ArbitraryTransactionData arbitraryTransactionData) { | ||||
|         String name = arbitraryTransactionData.getName(); | ||||
|  | ||||
|         // Only fetch data associated with hashes, as we already have RAW_DATA | ||||
|         if (arbitraryTransactionData.getDataType() != ArbitraryTransactionData.DataType.DATA_HASH) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // Don't fetch anything more if we're (nearly) out of space | ||||
|         // Make sure to keep STORAGE_FULL_THRESHOLD considerably less than 1, to | ||||
|         // avoid a fetch/delete loop | ||||
|         if (!this.isStorageSpaceAvailable(STORAGE_FULL_THRESHOLD)) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // Don't fetch anything if we're (nearly) out of space for this name | ||||
|         // Again, make sure to keep STORAGE_FULL_THRESHOLD considerably less than 1, to | ||||
|         // avoid a fetch/delete loop | ||||
|         if (!this.isStorageSpaceAvailableForName(repository, arbitraryTransactionData.getName(), STORAGE_FULL_THRESHOLD)) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // Don't store data unless it's an allowed type (public/private) | ||||
|         if (!this.isDataTypeAllowed(arbitraryTransactionData)) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // Handle transactions without names differently | ||||
|         if (name == null) { | ||||
|             return this.shouldPreFetchDataWithoutName(); | ||||
|         } | ||||
|  | ||||
|         // Never fetch data from blocked names, even if they are followed | ||||
|         if (this.isNameBlocked(name)) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         switch (Settings.getInstance().getStoragePolicy()) { | ||||
|             case FOLLOWED: | ||||
|             case FOLLOWED_OR_VIEWED: | ||||
|                 return this.isFollowingName(name); | ||||
|                  | ||||
|             case ALL: | ||||
|                 return true; | ||||
|  | ||||
|             case NONE: | ||||
|             case VIEWED: | ||||
|             default: | ||||
|                 return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Don't call this method directly. | ||||
|      * Use the wrapper method shouldPreFetchData() instead, as it contains | ||||
|      * additional checks. | ||||
|      * | ||||
|      * @return boolean - whether the storage policy allows for unnamed data | ||||
|      */ | ||||
|     private boolean shouldPreFetchDataWithoutName() { | ||||
|         switch (Settings.getInstance().getStoragePolicy()) { | ||||
|             case ALL: | ||||
|                 return true; | ||||
|  | ||||
|             case NONE: | ||||
|             case VIEWED: | ||||
|             case FOLLOWED: | ||||
|             case FOLLOWED_OR_VIEWED: | ||||
|             default: | ||||
|                 return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private boolean isDataTypeAllowed(ArbitraryTransactionData arbitraryTransactionData) { | ||||
|         byte[] secret = arbitraryTransactionData.getSecret(); | ||||
|         boolean hasSecret = (secret != null && secret.length == 32); | ||||
|  | ||||
|         if (!Settings.getInstance().isPrivateDataEnabled() && !hasSecret) { | ||||
|             // Private data isn't enabled so we can't store data without a valid secret | ||||
|             return false; | ||||
|         } | ||||
|         if (!Settings.getInstance().isPublicDataEnabled() && hasSecret) { | ||||
|             // Public data isn't enabled so we can't store data with a secret | ||||
|             return false; | ||||
|         } | ||||
|         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"); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     // Hosted data | ||||
|  | ||||
|     public List<ArbitraryTransactionData> listAllHostedTransactions(Repository repository, Integer limit, Integer offset) { | ||||
|         // Load from cache if we can, to avoid disk reads | ||||
|         if (this.hostedTransactions != null) { | ||||
|             return ArbitraryTransactionUtils.limitOffsetTransactions(this.hostedTransactions, limit, offset); | ||||
|         } | ||||
|  | ||||
|         List<ArbitraryTransactionData> arbitraryTransactionDataList = new ArrayList<>(); | ||||
|  | ||||
|         // Find all hosted paths | ||||
|         List<Path> allPaths = this.findAllHostedPaths(); | ||||
|  | ||||
|         // Loop through each path and attempt to match it to a signature | ||||
|         for (Path path : allPaths) { | ||||
|             try { | ||||
|                 String[] contents = path.toFile().list(); | ||||
|                 if (contents == null || contents.length == 0) { | ||||
|                     // Ignore empty directories | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 String signature58 = path.getFileName().toString(); | ||||
|                 byte[] signature = Base58.decode(signature58); | ||||
|                 TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); | ||||
|                 if (transactionData == null || transactionData.getType() != Transaction.TransactionType.ARBITRARY) { | ||||
|                     continue; | ||||
|                 } | ||||
|                 arbitraryTransactionDataList.add((ArbitraryTransactionData) transactionData); | ||||
|  | ||||
|             } catch (DataException e) { | ||||
|                 continue; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Sort by newest first | ||||
|         arbitraryTransactionDataList.sort(Comparator.comparingLong(ArbitraryTransactionData::getTimestamp).reversed()); | ||||
|  | ||||
|         // Update cache | ||||
|         this.hostedTransactions = arbitraryTransactionDataList; | ||||
|  | ||||
|         return ArbitraryTransactionUtils.limitOffsetTransactions(arbitraryTransactionDataList, limit, offset); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Warning: this method will walk through the entire data directory | ||||
|      * Do not call it too frequently as it could create high disk load | ||||
|      * in environments with a large amount of hosted data. | ||||
|      * @return a list of paths that are being hosted | ||||
|      */ | ||||
|     public List<Path> findAllHostedPaths() { | ||||
|         Path dataPath = Paths.get(Settings.getInstance().getDataPath()); | ||||
|         Path tempPath = Paths.get(Settings.getInstance().getTempDataPath()); | ||||
|  | ||||
|         // Walk through 3 levels of the file tree and find directories that are greater than 32 characters in length | ||||
|         // Also exclude the _temp and _misc paths if present | ||||
|         List<Path> allPaths = new ArrayList<>(); | ||||
|         try { | ||||
|             allPaths = Files.walk(dataPath, 3) | ||||
|                     .filter(Files::isDirectory) | ||||
|                     .filter(path -> !path.toAbsolutePath().toString().contains(tempPath.toAbsolutePath().toString()) | ||||
|                             && !path.toString().contains("_misc") | ||||
|                             && path.getFileName().toString().length() > 32) | ||||
|                     .collect(Collectors.toList()); | ||||
|         } | ||||
|         catch (IOException e) { | ||||
|             LOGGER.info("Unable to walk through hosted data: {}", e.getMessage()); | ||||
|         } | ||||
|  | ||||
|         return allPaths; | ||||
|     } | ||||
|  | ||||
|     public void invalidateHostedTransactionsCache() { | ||||
|         this.hostedTransactions = null; | ||||
|     } | ||||
|  | ||||
|  | ||||
|     // Size limits | ||||
|  | ||||
|     /** | ||||
|      * Rate limit to reduce IO load | ||||
|      */ | ||||
|     public boolean shouldCalculateDirectorySize(Long now) { | ||||
|         if (now == null) { | ||||
|             return false; | ||||
|         } | ||||
|         // If storage capacity is null, we need to calculate it | ||||
|         if (this.storageCapacity == null) { | ||||
|             return true; | ||||
|         } | ||||
|         // If we haven't checked for a while, we need to check it now | ||||
|         if (now - lastDirectorySizeCheck > DIRECTORY_SIZE_CHECK_INTERVAL) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         // We shouldn't check this time, as we want to reduce IO load on the SSD/HDD | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public void calculateDirectorySize(Long now) { | ||||
|         if (now == null) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         long totalSize = 0; | ||||
|         long remainingCapacity = 0; | ||||
|  | ||||
|         // Calculate remaining capacity | ||||
|         try { | ||||
|             remainingCapacity = this.getRemainingUsableStorageCapacity(); | ||||
|         } catch (IOException e) { | ||||
|             LOGGER.info("Unable to calculate remaining storage capacity: {}", e.getMessage()); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Calculate total size of data directory | ||||
|         LOGGER.trace("Calculating data directory size..."); | ||||
|         Path dataDirectoryPath = Paths.get(Settings.getInstance().getDataPath()); | ||||
|         if (dataDirectoryPath.toFile().exists()) { | ||||
|             totalSize += FileUtils.sizeOfDirectory(dataDirectoryPath.toFile()); | ||||
|         } | ||||
|  | ||||
|         // Add total size of temp directory, if it's not already inside the data directory | ||||
|         Path tempDirectoryPath = Paths.get(Settings.getInstance().getTempDataPath()); | ||||
|         if (tempDirectoryPath.toFile().exists()) { | ||||
|             if (!FilesystemUtils.isChild(tempDirectoryPath, dataDirectoryPath)) { | ||||
|                 LOGGER.trace("Calculating temp directory size..."); | ||||
|                 totalSize += FileUtils.sizeOfDirectory(dataDirectoryPath.toFile()); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         this.totalDirectorySize = totalSize; | ||||
|         this.lastDirectorySizeCheck = now; | ||||
|  | ||||
|         // It's essential that used space (this.totalDirectorySize) is included in the storage capacity | ||||
|         LOGGER.trace("Calculating total storage capacity..."); | ||||
|         long storageCapacity = remainingCapacity + this.totalDirectorySize; | ||||
|  | ||||
|         // Make sure to limit the storage capacity if the user is overriding it in the settings | ||||
|         if (Settings.getInstance().getMaxStorageCapacity() != null) { | ||||
|             storageCapacity = Math.min(storageCapacity, Settings.getInstance().getMaxStorageCapacity()); | ||||
|         } | ||||
|         this.storageCapacity = storageCapacity; | ||||
|  | ||||
|         LOGGER.info("Total used: {} bytes, Total capacity: {} bytes", this.totalDirectorySize, this.storageCapacity); | ||||
|     } | ||||
|  | ||||
|     private long getRemainingUsableStorageCapacity() throws IOException { | ||||
|         // Create data directory if it doesn't exist so that we can perform calculations on it | ||||
|         Path dataDirectoryPath = Paths.get(Settings.getInstance().getDataPath()); | ||||
|         if (!dataDirectoryPath.toFile().exists()) { | ||||
|             Files.createDirectories(dataDirectoryPath); | ||||
|         } | ||||
|  | ||||
|         return dataDirectoryPath.toFile().getUsableSpace(); | ||||
|     } | ||||
|  | ||||
|     public long getTotalDirectorySize() { | ||||
|         return this.totalDirectorySize; | ||||
|     } | ||||
|  | ||||
|     public boolean isStorageSpaceAvailable(double threshold) { | ||||
|         if (!this.isStorageCapacityCalculated()) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         long maxStorageCapacity = (long)((double)this.storageCapacity * threshold); | ||||
|         if (this.totalDirectorySize >= maxStorageCapacity) { | ||||
|             return false; | ||||
|         } | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public boolean isStorageSpaceAvailableForName(Repository repository, String name, double threshold) { | ||||
|         if (!this.isStorageSpaceAvailable(threshold)) { | ||||
|             // No storage space available at all, so no need to check this name | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (name == null) { | ||||
|             // This transaction doesn't have a name, so fall back to total space limitations | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         int followedNamesCount = this.followedNamesCount(); | ||||
|         if (followedNamesCount == 0) { | ||||
|             // Not following any names, so we have space | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         long totalSizeForName = 0; | ||||
|         long maxStoragePerName = this.storageCapacityPerName(threshold); | ||||
|  | ||||
|         // Fetch all hosted transactions | ||||
|         List<ArbitraryTransactionData> hostedTransactions = this.listAllHostedTransactions(repository, null, null); | ||||
|         for (ArbitraryTransactionData transactionData : hostedTransactions) { | ||||
|             String transactionName = transactionData.getName(); | ||||
|             if (!Objects.equals(name, transactionName)) { | ||||
|                 // Transaction relates to a different name | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             totalSizeForName += transactionData.getSize(); | ||||
|         } | ||||
|  | ||||
|         // Have we reached the limit for this name? | ||||
|         if (totalSizeForName > maxStoragePerName) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public long storageCapacityPerName(double threshold) { | ||||
|         int followedNamesCount = this.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); | ||||
|  | ||||
|         return maxStoragePerName; | ||||
|     } | ||||
|  | ||||
|     public boolean isStorageCapacityCalculated() { | ||||
|         return (this.storageCapacity != null); | ||||
|     } | ||||
|  | ||||
|     public Long getStorageCapacity() { | ||||
|         return this.storageCapacity; | ||||
|     } | ||||
|  | ||||
|     public Long getStorageCapacityIncludingThreshold(double threshold) { | ||||
|         if (this.storageCapacity == null) { | ||||
|             return null; | ||||
|         } | ||||
|         return (long)(this.storageCapacity * threshold); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,110 @@ | ||||
| package org.qortal.controller.repository; | ||||
|  | ||||
| import org.apache.logging.log4j.LogManager; | ||||
| import org.apache.logging.log4j.Logger; | ||||
| import org.qortal.controller.Controller; | ||||
| import org.qortal.controller.Synchronizer; | ||||
| import org.qortal.data.block.BlockData; | ||||
| import org.qortal.repository.DataException; | ||||
| import org.qortal.repository.Repository; | ||||
| import org.qortal.repository.RepositoryManager; | ||||
| import org.qortal.settings.Settings; | ||||
| import org.qortal.utils.NTP; | ||||
|  | ||||
| public class AtStatesPruner implements Runnable { | ||||
|  | ||||
| 	private static final Logger LOGGER = LogManager.getLogger(AtStatesPruner.class); | ||||
|  | ||||
| 	@Override | ||||
| 	public void run() { | ||||
| 		Thread.currentThread().setName("AT States pruner"); | ||||
|  | ||||
| 		boolean archiveMode = false; | ||||
| 		if (!Settings.getInstance().isTopOnly()) { | ||||
| 			// Top-only mode isn't enabled, but we might want to prune for the purposes of archiving | ||||
| 			if (!Settings.getInstance().isArchiveEnabled()) { | ||||
| 				// No pruning or archiving, so we must not prune anything | ||||
| 				return; | ||||
| 			} | ||||
| 			else { | ||||
| 				// We're allowed to prune blocks that have already been archived | ||||
| 				archiveMode = true; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		try (final Repository repository = RepositoryManager.getRepository()) { | ||||
| 			int pruneStartHeight = repository.getATRepository().getAtPruneHeight(); | ||||
|  | ||||
| 			repository.discardChanges(); | ||||
| 			repository.getATRepository().rebuildLatestAtStates(); | ||||
|  | ||||
| 			while (!Controller.isStopping()) { | ||||
| 				repository.discardChanges(); | ||||
|  | ||||
| 				Thread.sleep(Settings.getInstance().getAtStatesPruneInterval()); | ||||
|  | ||||
| 				BlockData chainTip = Controller.getInstance().getChainTip(); | ||||
| 				if (chainTip == null || NTP.getTime() == null) | ||||
| 					continue; | ||||
|  | ||||
| 				// Don't even attempt if we're mid-sync as our repository requests will be delayed for ages | ||||
| 				if (Synchronizer.getInstance().isSynchronizing()) | ||||
| 					continue; | ||||
|  | ||||
| 				// Prune AT states for all blocks up until our latest minus pruneBlockLimit | ||||
| 				final int ourLatestHeight = chainTip.getHeight(); | ||||
| 				int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); | ||||
|  | ||||
| 				// In archive mode we are only allowed to trim blocks that have already been archived | ||||
| 				if (archiveMode) { | ||||
| 					upperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; | ||||
|  | ||||
| 					// TODO: validate that the actual archived data exists before pruning it? | ||||
| 				} | ||||
|  | ||||
| 				int upperBatchHeight = pruneStartHeight + Settings.getInstance().getAtStatesPruneBatchSize(); | ||||
| 				int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); | ||||
|  | ||||
| 				if (pruneStartHeight >= upperPruneHeight) | ||||
| 					continue; | ||||
|  | ||||
| 				LOGGER.debug(String.format("Pruning AT states between blocks %d and %d...", pruneStartHeight, upperPruneHeight)); | ||||
|  | ||||
| 				int numAtStatesPruned = repository.getATRepository().pruneAtStates(pruneStartHeight, upperPruneHeight); | ||||
| 				repository.saveChanges(); | ||||
| 				int numAtStateDataRowsTrimmed = repository.getATRepository().trimAtStates( | ||||
| 						pruneStartHeight, upperPruneHeight, Settings.getInstance().getAtStatesTrimLimit()); | ||||
| 				repository.saveChanges(); | ||||
|  | ||||
| 				if (numAtStatesPruned > 0 || numAtStateDataRowsTrimmed > 0) { | ||||
| 					final int finalPruneStartHeight = pruneStartHeight; | ||||
| 					LOGGER.debug(() -> String.format("Pruned %d AT state%s between blocks %d and %d", | ||||
| 							numAtStatesPruned, (numAtStatesPruned != 1 ? "s" : ""), | ||||
| 							finalPruneStartHeight, upperPruneHeight)); | ||||
| 				} else { | ||||
| 					// Can we move onto next batch? | ||||
| 					if (upperPrunableHeight > upperBatchHeight) { | ||||
| 						pruneStartHeight = upperBatchHeight; | ||||
| 						repository.getATRepository().setAtPruneHeight(pruneStartHeight); | ||||
| 						repository.getATRepository().rebuildLatestAtStates(); | ||||
| 						repository.saveChanges(); | ||||
|  | ||||
| 						final int finalPruneStartHeight = pruneStartHeight; | ||||
| 						LOGGER.debug(() -> String.format("Bumping AT state base prune height to %d", finalPruneStartHeight)); | ||||
| 					} | ||||
| 					else { | ||||
| 						// We've pruned up to the upper prunable height | ||||
| 						// Back off for a while to save CPU for syncing | ||||
| 						repository.discardChanges(); | ||||
| 						Thread.sleep(5*60*1000L); | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} catch (DataException e) { | ||||
| 			LOGGER.warn(String.format("Repository issue trying to prune AT states: %s", e.getMessage())); | ||||
| 		} catch (InterruptedException e) { | ||||
| 			// Time to exit | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user