Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c82ad8716 | ||
|
|
b1e730c455 | ||
|
|
975f67d473 | ||
|
|
247173a676 | ||
|
|
4a301f54c1 | ||
|
|
c6f107397e | ||
|
|
ffd1abbdb3 | ||
|
|
742ccc4806 | ||
|
|
540e628e60 | ||
|
|
79bc62a5d2 | ||
|
|
1c43bd363a | ||
|
|
4f404a1ccf | ||
|
|
2ae413c5ea | ||
|
|
36358fc139 | ||
|
|
d39a7ddda7 | ||
|
|
789eaa263f | ||
|
|
4575d7178b | ||
|
|
3cdf363eab | ||
|
|
d990d4181d | ||
|
|
371243de31 | ||
|
|
de017e0751 | ||
|
|
6faa0ed599 | ||
|
|
a46dc141d0 | ||
|
|
3c021c131a | ||
|
|
bea401cf19 | ||
|
|
6fee27739c | ||
|
|
1cc562a6b1 | ||
|
|
d90e51233d | ||
|
|
1cd3e2846e | ||
|
|
080faf9353 | ||
|
|
d486bb4939 | ||
|
|
41f9929fd2 | ||
|
|
0b0db1793e | ||
|
|
f3cf747cfe | ||
|
|
e3982ed4c5 | ||
|
|
5c7d675960 | ||
|
|
7fc6ec5fe1 | ||
|
|
7722001463 | ||
|
|
43d4b46b31 | ||
|
|
eee17d4d19 | ||
|
|
c40144db19 | ||
|
|
c77cd73deb | ||
|
|
b76f3662e4 | ||
|
|
188408fc1e | ||
|
|
0b86dd79d3 | ||
|
|
0436460c03 | ||
|
|
4d84dcdd42 | ||
|
|
039e3b5fa8 | ||
|
|
15c5b0eb92 | ||
|
|
36c9369404 | ||
|
|
43f3261972 | ||
|
|
3d7efc5d94 | ||
|
|
ebf256fd50 | ||
|
|
f678e1d2e3 | ||
|
|
81c14351ee | ||
|
|
3711d8d6b1 | ||
|
|
a3b2a9b3e3 | ||
|
|
dd7925fbf4 | ||
|
|
1b1d17f6f5 | ||
|
|
58a00dde24 | ||
|
|
a95583b9e0 | ||
|
|
56f788957a | ||
|
|
df1295059a | ||
|
|
853161e891 | ||
|
|
64b33ea62b | ||
|
|
0ca4600371 |
@@ -59,8 +59,8 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
return duplicates
|
||||
|
||||
|
||||
# Maximum size for TOML files (e.g., 570 KB)
|
||||
MAX_FILE_SIZE = 570 * 1024
|
||||
# Maximum size for TOML files (e.g., 1 MB)
|
||||
MAX_FILE_SIZE = 1000 * 1024
|
||||
|
||||
|
||||
def parse_toml_file(file_path):
|
||||
|
||||
@@ -97,9 +97,9 @@ cffi==2.0.0 \
|
||||
--hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \
|
||||
--hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf
|
||||
# via weasyprint
|
||||
cfgv==3.4.0 \
|
||||
--hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
|
||||
--hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560
|
||||
cfgv==3.5.0 \
|
||||
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
|
||||
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
|
||||
# via pre-commit
|
||||
cssselect2==0.8.0 \
|
||||
--hash=sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e \
|
||||
@@ -109,259 +109,269 @@ distlib==0.4.0 \
|
||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
||||
# via virtualenv
|
||||
filelock==3.20.0 \
|
||||
--hash=sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2 \
|
||||
--hash=sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4
|
||||
filelock==3.20.3 \
|
||||
--hash=sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1 \
|
||||
--hash=sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1
|
||||
# via virtualenv
|
||||
fonttools==4.60.1 \
|
||||
--hash=sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c \
|
||||
--hash=sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc \
|
||||
--hash=sha256:0b0835ed15dd5b40d726bb61c846a688f5b4ce2208ec68779bc81860adb5851a \
|
||||
--hash=sha256:0eae96373e4b7c9e45d099d7a523444e3554360927225c1cdae221a58a45b856 \
|
||||
--hash=sha256:122e1a8ada290423c493491d002f622b1992b1ab0b488c68e31c413390dc7eb2 \
|
||||
--hash=sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259 \
|
||||
--hash=sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c \
|
||||
--hash=sha256:1525796c3ffe27bb6268ed2a1bb0dcf214d561dfaf04728abf01489eb5339dce \
|
||||
--hash=sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003 \
|
||||
--hash=sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272 \
|
||||
--hash=sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77 \
|
||||
--hash=sha256:268ecda8ca6cb5c4f044b1fb9b3b376e8cd1b361cef275082429dc4174907038 \
|
||||
--hash=sha256:282dafa55f9659e8999110bd8ed422ebe1c8aecd0dc396550b038e6c9a08b8ea \
|
||||
--hash=sha256:2ee06fc57512144d8b0445194c2da9f190f61ad51e230f14836286470c99f854 \
|
||||
--hash=sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2 \
|
||||
--hash=sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258 \
|
||||
--hash=sha256:4ba4bd646e86de16160f0fb72e31c3b9b7d0721c3e5b26b9fa2fc931dfdb2652 \
|
||||
--hash=sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08 \
|
||||
--hash=sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99 \
|
||||
--hash=sha256:596ecaca36367027d525b3b426d8a8208169d09edcf8c7506aceb3a38bfb55c7 \
|
||||
--hash=sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914 \
|
||||
--hash=sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6 \
|
||||
--hash=sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed \
|
||||
--hash=sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb \
|
||||
--hash=sha256:7473a8ed9ed09aeaa191301244a5a9dbe46fe0bf54f9d6cd21d83044c3321217 \
|
||||
--hash=sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc \
|
||||
--hash=sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f \
|
||||
--hash=sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c \
|
||||
--hash=sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877 \
|
||||
--hash=sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801 \
|
||||
--hash=sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85 \
|
||||
--hash=sha256:8b4eb332f9501cb1cd3d4d099374a1e1306783ff95489a1026bde9eb02ccc34a \
|
||||
--hash=sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb \
|
||||
--hash=sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383 \
|
||||
--hash=sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401 \
|
||||
--hash=sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28 \
|
||||
--hash=sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01 \
|
||||
--hash=sha256:a140761c4ff63d0cb9256ac752f230460ee225ccef4ad8f68affc723c88e2036 \
|
||||
--hash=sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc \
|
||||
--hash=sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac \
|
||||
--hash=sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903 \
|
||||
--hash=sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3 \
|
||||
--hash=sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6 \
|
||||
--hash=sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c \
|
||||
--hash=sha256:b42d86938e8dda1cd9a1a87a6d82f1818eaf933348429653559a458d027446da \
|
||||
--hash=sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299 \
|
||||
--hash=sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15 \
|
||||
--hash=sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199 \
|
||||
--hash=sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf \
|
||||
--hash=sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1 \
|
||||
--hash=sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537 \
|
||||
--hash=sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d \
|
||||
--hash=sha256:ee0c0b3b35b34f782afc673d503167157094a16f442ace7c6c5e0ca80b08f50c \
|
||||
--hash=sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4 \
|
||||
--hash=sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9 \
|
||||
--hash=sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed \
|
||||
--hash=sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987 \
|
||||
--hash=sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa
|
||||
fonttools==4.61.1 \
|
||||
--hash=sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87 \
|
||||
--hash=sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796 \
|
||||
--hash=sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75 \
|
||||
--hash=sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d \
|
||||
--hash=sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371 \
|
||||
--hash=sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b \
|
||||
--hash=sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b \
|
||||
--hash=sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2 \
|
||||
--hash=sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3 \
|
||||
--hash=sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9 \
|
||||
--hash=sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd \
|
||||
--hash=sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c \
|
||||
--hash=sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c \
|
||||
--hash=sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56 \
|
||||
--hash=sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37 \
|
||||
--hash=sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0 \
|
||||
--hash=sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958 \
|
||||
--hash=sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5 \
|
||||
--hash=sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118 \
|
||||
--hash=sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69 \
|
||||
--hash=sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9 \
|
||||
--hash=sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261 \
|
||||
--hash=sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb \
|
||||
--hash=sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47 \
|
||||
--hash=sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24 \
|
||||
--hash=sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c \
|
||||
--hash=sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba \
|
||||
--hash=sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c \
|
||||
--hash=sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91 \
|
||||
--hash=sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1 \
|
||||
--hash=sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19 \
|
||||
--hash=sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6 \
|
||||
--hash=sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5 \
|
||||
--hash=sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2 \
|
||||
--hash=sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d \
|
||||
--hash=sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881 \
|
||||
--hash=sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063 \
|
||||
--hash=sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7 \
|
||||
--hash=sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09 \
|
||||
--hash=sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da \
|
||||
--hash=sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e \
|
||||
--hash=sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e \
|
||||
--hash=sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8 \
|
||||
--hash=sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa \
|
||||
--hash=sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6 \
|
||||
--hash=sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e \
|
||||
--hash=sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a \
|
||||
--hash=sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c \
|
||||
--hash=sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7 \
|
||||
--hash=sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd
|
||||
# via weasyprint
|
||||
identify==2.6.15 \
|
||||
--hash=sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757 \
|
||||
--hash=sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf
|
||||
identify==2.6.16 \
|
||||
--hash=sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0 \
|
||||
--hash=sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980
|
||||
# via pre-commit
|
||||
nodeenv==1.9.1 \
|
||||
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
|
||||
--hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
|
||||
nodeenv==1.10.0 \
|
||||
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
|
||||
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
|
||||
# via pre-commit
|
||||
numpy==2.2.6 \
|
||||
--hash=sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff \
|
||||
--hash=sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47 \
|
||||
--hash=sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84 \
|
||||
--hash=sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d \
|
||||
--hash=sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6 \
|
||||
--hash=sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f \
|
||||
--hash=sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b \
|
||||
--hash=sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49 \
|
||||
--hash=sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163 \
|
||||
--hash=sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571 \
|
||||
--hash=sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42 \
|
||||
--hash=sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff \
|
||||
--hash=sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491 \
|
||||
--hash=sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4 \
|
||||
--hash=sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566 \
|
||||
--hash=sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf \
|
||||
--hash=sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40 \
|
||||
--hash=sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd \
|
||||
--hash=sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06 \
|
||||
--hash=sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282 \
|
||||
--hash=sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680 \
|
||||
--hash=sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db \
|
||||
--hash=sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3 \
|
||||
--hash=sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90 \
|
||||
--hash=sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1 \
|
||||
--hash=sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289 \
|
||||
--hash=sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab \
|
||||
--hash=sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c \
|
||||
--hash=sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d \
|
||||
--hash=sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb \
|
||||
--hash=sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d \
|
||||
--hash=sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a \
|
||||
--hash=sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf \
|
||||
--hash=sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1 \
|
||||
--hash=sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2 \
|
||||
--hash=sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a \
|
||||
--hash=sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543 \
|
||||
--hash=sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00 \
|
||||
--hash=sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c \
|
||||
--hash=sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f \
|
||||
--hash=sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd \
|
||||
--hash=sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868 \
|
||||
--hash=sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303 \
|
||||
--hash=sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83 \
|
||||
--hash=sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3 \
|
||||
--hash=sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d \
|
||||
--hash=sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87 \
|
||||
--hash=sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa \
|
||||
--hash=sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f \
|
||||
--hash=sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae \
|
||||
--hash=sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda \
|
||||
--hash=sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915 \
|
||||
--hash=sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249 \
|
||||
--hash=sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de \
|
||||
--hash=sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8
|
||||
numpy==2.4.1 \
|
||||
--hash=sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c \
|
||||
--hash=sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba \
|
||||
--hash=sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5 \
|
||||
--hash=sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8 \
|
||||
--hash=sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0 \
|
||||
--hash=sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d \
|
||||
--hash=sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574 \
|
||||
--hash=sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696 \
|
||||
--hash=sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5 \
|
||||
--hash=sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505 \
|
||||
--hash=sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0 \
|
||||
--hash=sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162 \
|
||||
--hash=sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844 \
|
||||
--hash=sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205 \
|
||||
--hash=sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4 \
|
||||
--hash=sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc \
|
||||
--hash=sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d \
|
||||
--hash=sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93 \
|
||||
--hash=sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01 \
|
||||
--hash=sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c \
|
||||
--hash=sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f \
|
||||
--hash=sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33 \
|
||||
--hash=sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82 \
|
||||
--hash=sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2 \
|
||||
--hash=sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42 \
|
||||
--hash=sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509 \
|
||||
--hash=sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a \
|
||||
--hash=sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e \
|
||||
--hash=sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556 \
|
||||
--hash=sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a \
|
||||
--hash=sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510 \
|
||||
--hash=sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295 \
|
||||
--hash=sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73 \
|
||||
--hash=sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3 \
|
||||
--hash=sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9 \
|
||||
--hash=sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8 \
|
||||
--hash=sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745 \
|
||||
--hash=sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2 \
|
||||
--hash=sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02 \
|
||||
--hash=sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d \
|
||||
--hash=sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344 \
|
||||
--hash=sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f \
|
||||
--hash=sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be \
|
||||
--hash=sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425 \
|
||||
--hash=sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1 \
|
||||
--hash=sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2 \
|
||||
--hash=sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2 \
|
||||
--hash=sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb \
|
||||
--hash=sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9 \
|
||||
--hash=sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15 \
|
||||
--hash=sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690 \
|
||||
--hash=sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0 \
|
||||
--hash=sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261 \
|
||||
--hash=sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a \
|
||||
--hash=sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc \
|
||||
--hash=sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f \
|
||||
--hash=sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5 \
|
||||
--hash=sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df \
|
||||
--hash=sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9 \
|
||||
--hash=sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2 \
|
||||
--hash=sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8 \
|
||||
--hash=sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426 \
|
||||
--hash=sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b \
|
||||
--hash=sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87 \
|
||||
--hash=sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220 \
|
||||
--hash=sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b \
|
||||
--hash=sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3 \
|
||||
--hash=sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e \
|
||||
--hash=sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501 \
|
||||
--hash=sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee \
|
||||
--hash=sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7 \
|
||||
--hash=sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c
|
||||
# via opencv-python-headless
|
||||
opencv-python-headless==4.12.0.88 \
|
||||
--hash=sha256:1e58d664809b3350c1123484dd441e1667cd7bed3086db1b9ea1b6f6cb20b50e \
|
||||
--hash=sha256:236c8df54a90f4d02076e6f9c1cc763d794542e886c576a6fee46ec8ff75a7a9 \
|
||||
--hash=sha256:365bb2e486b50feffc2d07a405b953a8f3e8eaa63865bc650034e5c71e7a5154 \
|
||||
--hash=sha256:86b413bdd6c6bf497832e346cd5371995de148e579b9774f8eba686dee3f5528 \
|
||||
--hash=sha256:aeb4b13ecb8b4a0beb2668ea07928160ea7c2cd2d9b5ef571bbee6bafe9cc8d0 \
|
||||
--hash=sha256:cfdc017ddf2e59b6c2f53bc12d74b6b0be7ded4ec59083ea70763921af2b6c09 \
|
||||
--hash=sha256:fde2cf5c51e4def5f2132d78e0c08f9c14783cd67356922182c6845b9af87dbd
|
||||
opencv-python-headless==4.13.0.90 \
|
||||
--hash=sha256:0e0c8c9f620802fddc4fa7f471a1d263c7b0dca16cd9e7e2f996bb8bd2128c0c \
|
||||
--hash=sha256:12a28674f215542c9bf93338de1b5bffd76996d32da9acb9e739fdb9c8bbd738 \
|
||||
--hash=sha256:32255203040dc98803be96362e13f9e4bce20146898222d2e5c242f80de50da5 \
|
||||
--hash=sha256:96060fc57a1abb1144b0b8129e2ff3bfcdd0ccd8e8bd05bd85256ff4ed587d3b \
|
||||
--hash=sha256:dbc1f4625e5af3a80ebdbd84380227c0f445228588f2521b11af47710caca1ba \
|
||||
--hash=sha256:e13790342591557050157713af17a7435ac1b50c65282715093c9297fa045d8f \
|
||||
--hash=sha256:eba38bc255d0b7d1969c5bcc90a060ca2b61a3403b613872c750bfa5dfe9e03b \
|
||||
--hash=sha256:f46b17ea0aa7e4124ca6ad71143f89233ae9557f61d2326bcdb34329a1ddf9bd
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
pdf2image==1.17.0 \
|
||||
--hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \
|
||||
--hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
pillow==12.0.0 \
|
||||
--hash=sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643 \
|
||||
--hash=sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e \
|
||||
--hash=sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e \
|
||||
--hash=sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc \
|
||||
--hash=sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642 \
|
||||
--hash=sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6 \
|
||||
--hash=sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1 \
|
||||
--hash=sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b \
|
||||
--hash=sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399 \
|
||||
--hash=sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba \
|
||||
--hash=sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad \
|
||||
--hash=sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47 \
|
||||
--hash=sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739 \
|
||||
--hash=sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b \
|
||||
--hash=sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f \
|
||||
--hash=sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10 \
|
||||
--hash=sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52 \
|
||||
--hash=sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d \
|
||||
--hash=sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b \
|
||||
--hash=sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a \
|
||||
--hash=sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9 \
|
||||
--hash=sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d \
|
||||
--hash=sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098 \
|
||||
--hash=sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905 \
|
||||
--hash=sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b \
|
||||
--hash=sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3 \
|
||||
--hash=sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371 \
|
||||
--hash=sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953 \
|
||||
--hash=sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01 \
|
||||
--hash=sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca \
|
||||
--hash=sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e \
|
||||
--hash=sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7 \
|
||||
--hash=sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27 \
|
||||
--hash=sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082 \
|
||||
--hash=sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e \
|
||||
--hash=sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d \
|
||||
--hash=sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8 \
|
||||
--hash=sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a \
|
||||
--hash=sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad \
|
||||
--hash=sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3 \
|
||||
--hash=sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a \
|
||||
--hash=sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d \
|
||||
--hash=sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353 \
|
||||
--hash=sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee \
|
||||
--hash=sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b \
|
||||
--hash=sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b \
|
||||
--hash=sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a \
|
||||
--hash=sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7 \
|
||||
--hash=sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef \
|
||||
--hash=sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a \
|
||||
--hash=sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a \
|
||||
--hash=sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257 \
|
||||
--hash=sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07 \
|
||||
--hash=sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4 \
|
||||
--hash=sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c \
|
||||
--hash=sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c \
|
||||
--hash=sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4 \
|
||||
--hash=sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe \
|
||||
--hash=sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8 \
|
||||
--hash=sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5 \
|
||||
--hash=sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6 \
|
||||
--hash=sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e \
|
||||
--hash=sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8 \
|
||||
--hash=sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e \
|
||||
--hash=sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275 \
|
||||
--hash=sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3 \
|
||||
--hash=sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76 \
|
||||
--hash=sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227 \
|
||||
--hash=sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9 \
|
||||
--hash=sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5 \
|
||||
--hash=sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79 \
|
||||
--hash=sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca \
|
||||
--hash=sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa \
|
||||
--hash=sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b \
|
||||
--hash=sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e \
|
||||
--hash=sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197 \
|
||||
--hash=sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab \
|
||||
--hash=sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79 \
|
||||
--hash=sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2 \
|
||||
--hash=sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363 \
|
||||
--hash=sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0 \
|
||||
--hash=sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e \
|
||||
--hash=sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782 \
|
||||
--hash=sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925 \
|
||||
--hash=sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0 \
|
||||
--hash=sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b \
|
||||
--hash=sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced \
|
||||
--hash=sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c \
|
||||
--hash=sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344 \
|
||||
--hash=sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9 \
|
||||
--hash=sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1
|
||||
pillow==12.1.0 \
|
||||
--hash=sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d \
|
||||
--hash=sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc \
|
||||
--hash=sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84 \
|
||||
--hash=sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de \
|
||||
--hash=sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0 \
|
||||
--hash=sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef \
|
||||
--hash=sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4 \
|
||||
--hash=sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82 \
|
||||
--hash=sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9 \
|
||||
--hash=sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030 \
|
||||
--hash=sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0 \
|
||||
--hash=sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18 \
|
||||
--hash=sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a \
|
||||
--hash=sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef \
|
||||
--hash=sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b \
|
||||
--hash=sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6 \
|
||||
--hash=sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179 \
|
||||
--hash=sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e \
|
||||
--hash=sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72 \
|
||||
--hash=sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64 \
|
||||
--hash=sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451 \
|
||||
--hash=sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd \
|
||||
--hash=sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924 \
|
||||
--hash=sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616 \
|
||||
--hash=sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a \
|
||||
--hash=sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94 \
|
||||
--hash=sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc \
|
||||
--hash=sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8 \
|
||||
--hash=sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9 \
|
||||
--hash=sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91 \
|
||||
--hash=sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a \
|
||||
--hash=sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c \
|
||||
--hash=sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670 \
|
||||
--hash=sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea \
|
||||
--hash=sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91 \
|
||||
--hash=sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c \
|
||||
--hash=sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc \
|
||||
--hash=sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0 \
|
||||
--hash=sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b \
|
||||
--hash=sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65 \
|
||||
--hash=sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661 \
|
||||
--hash=sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19 \
|
||||
--hash=sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1 \
|
||||
--hash=sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0 \
|
||||
--hash=sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e \
|
||||
--hash=sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75 \
|
||||
--hash=sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4 \
|
||||
--hash=sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8 \
|
||||
--hash=sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd \
|
||||
--hash=sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7 \
|
||||
--hash=sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61 \
|
||||
--hash=sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51 \
|
||||
--hash=sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551 \
|
||||
--hash=sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45 \
|
||||
--hash=sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1 \
|
||||
--hash=sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644 \
|
||||
--hash=sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796 \
|
||||
--hash=sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587 \
|
||||
--hash=sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304 \
|
||||
--hash=sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b \
|
||||
--hash=sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8 \
|
||||
--hash=sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17 \
|
||||
--hash=sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171 \
|
||||
--hash=sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3 \
|
||||
--hash=sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7 \
|
||||
--hash=sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988 \
|
||||
--hash=sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a \
|
||||
--hash=sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0 \
|
||||
--hash=sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c \
|
||||
--hash=sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2 \
|
||||
--hash=sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14 \
|
||||
--hash=sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5 \
|
||||
--hash=sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a \
|
||||
--hash=sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377 \
|
||||
--hash=sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0 \
|
||||
--hash=sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5 \
|
||||
--hash=sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b \
|
||||
--hash=sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d \
|
||||
--hash=sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac \
|
||||
--hash=sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c \
|
||||
--hash=sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554 \
|
||||
--hash=sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643 \
|
||||
--hash=sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13 \
|
||||
--hash=sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09 \
|
||||
--hash=sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208 \
|
||||
--hash=sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda \
|
||||
--hash=sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea \
|
||||
--hash=sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e \
|
||||
--hash=sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0 \
|
||||
--hash=sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831 \
|
||||
--hash=sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd
|
||||
# via
|
||||
# -r .github/scripts/requirements_dev.in
|
||||
# pdf2image
|
||||
# weasyprint
|
||||
platformdirs==4.5.0 \
|
||||
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
|
||||
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
|
||||
platformdirs==4.5.1 \
|
||||
--hash=sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda \
|
||||
--hash=sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31
|
||||
# via virtualenv
|
||||
pre-commit==4.3.0 \
|
||||
--hash=sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8 \
|
||||
--hash=sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16
|
||||
pre-commit==4.5.1 \
|
||||
--hash=sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77 \
|
||||
--hash=sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
pycparser==2.23 \
|
||||
--hash=sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2 \
|
||||
--hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934
|
||||
pycparser==3.0 \
|
||||
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
|
||||
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
|
||||
# via cffi
|
||||
pydyf==0.11.0 \
|
||||
--hash=sha256:0aaf9e2ebbe786ec7a78ec3fbffa4cdcecde53fd6f563221d53c6bc1328848a3 \
|
||||
--hash=sha256:394dddf619cca9d0c55715e3c55ea121a9bf9cbc780cdc1201a2427917b86b64
|
||||
pydyf==0.12.1 \
|
||||
--hash=sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc \
|
||||
--hash=sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095
|
||||
# via weasyprint
|
||||
pyphen==0.17.2 \
|
||||
--hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \
|
||||
@@ -442,9 +452,9 @@ pyyaml==6.0.3 \
|
||||
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
|
||||
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
|
||||
# via pre-commit
|
||||
tinycss2==1.4.0 \
|
||||
--hash=sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7 \
|
||||
--hash=sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289
|
||||
tinycss2==1.5.1 \
|
||||
--hash=sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661 \
|
||||
--hash=sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957
|
||||
# via
|
||||
# cssselect2
|
||||
# weasyprint
|
||||
@@ -452,17 +462,17 @@ tinyhtml5==2.0.0 \
|
||||
--hash=sha256:086f998833da24c300c414d9fe81d9b368fd04cb9d2596a008421cbc705fcfcc \
|
||||
--hash=sha256:13683277c5b176d070f82d099d977194b7a1e26815b016114f581a74bbfbf47e
|
||||
# via weasyprint
|
||||
unoserver==3.4 \
|
||||
--hash=sha256:3dcf2204013def1d1ddd3671f38b11346bdf349fef9728277462666a8a634419 \
|
||||
--hash=sha256:64c24d33d4f65d680a2d9f676518cb28e7fd6c1f9d9a745c33e4a4cb59afdfcd
|
||||
unoserver==3.6 \
|
||||
--hash=sha256:25c360fa194396a89cb79b4edd2735f8e4f0fd8531e59db3952114585bd7df05 \
|
||||
--hash=sha256:e446bcb3638c51880f002aaeecab1cf74dfa9df81035f027f7ff2e081b6d7015
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
virtualenv==20.35.4 \
|
||||
--hash=sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c \
|
||||
--hash=sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b
|
||||
virtualenv==20.36.1 \
|
||||
--hash=sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f \
|
||||
--hash=sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba
|
||||
# via pre-commit
|
||||
weasyprint==66.0 \
|
||||
--hash=sha256:82b0783b726fcd318e2c977dcdddca76515b30044bc7a830cc4fbe717582a6d0 \
|
||||
--hash=sha256:da71dc87dc129ac9cffdc65e5477e90365ab9dbae45c744014ec1d06303dde40
|
||||
weasyprint==68.0 \
|
||||
--hash=sha256:447f40898b747cb44ac31a5d493d512e7441fd56e13f63744c099383bbf9cda9 \
|
||||
--hash=sha256:c2cb40c71b50837c5971f00171c9e4078e8c9912dd7c217f3e90e068f11e8aa1
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
webencodings==0.5.1 \
|
||||
--hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \
|
||||
@@ -471,86 +481,27 @@ webencodings==0.5.1 \
|
||||
# cssselect2
|
||||
# tinycss2
|
||||
# tinyhtml5
|
||||
zopfli==0.2.3.post1 \
|
||||
--hash=sha256:0aa5f90d6298bda02a95bc8dc8c3c19004d5a4e44bda00b67ca7431d857b4b54 \
|
||||
--hash=sha256:0cc20b02a9531559945324c38302fd4ba763311632d0ec8a1a0aa9c10ea363e6 \
|
||||
--hash=sha256:1d8cc06605519e82b16df090e17cb3990d1158861b2872c3117f1168777b81e4 \
|
||||
--hash=sha256:1f990634fd5c5c8ced8edddd8bd45fab565123b4194d6841e01811292650acae \
|
||||
--hash=sha256:2345e713260a350bea0b01a816a469ea356bc2d63d009a0d777691ecbbcf7493 \
|
||||
--hash=sha256:2768c877f76c8a0e7519b1c86c93757f3c01492ddde55751e9988afb7eff64e1 \
|
||||
--hash=sha256:29ea74e72ffa6e291b8c6f2504ce6c146b4fe990c724c1450eb8e4c27fd31431 \
|
||||
--hash=sha256:34a99592f3d9eb6f737616b5bd74b48a589fdb3cb59a01a50d636ea81d6af272 \
|
||||
--hash=sha256:3654bfc927bc478b1c3f3ff5056ed7b20a1a37fa108ca503256d0a699c03bbb1 \
|
||||
--hash=sha256:3657e416ffb8f31d9d3424af12122bb251befae109f2e271d87d825c92fc5b7b \
|
||||
--hash=sha256:37d011e92f7b9622742c905fdbed9920a1d0361df84142807ea2a528419dea7f \
|
||||
--hash=sha256:3827170de28faf144992d3d4dcf8f3998fe3c8a6a6f4a08f1d42c2ec6119d2bb \
|
||||
--hash=sha256:39e576f93576c5c223b41d9c780bbb91fd6db4babf3223d2a4fe7bf568e2b5a8 \
|
||||
--hash=sha256:3a89277ed5f8c0fb2d0b46d669aa0633123aa7381f1f6118c12f15e0fb48f8ca \
|
||||
--hash=sha256:3c163911f8bad94b3e1db0a572e7c28ba681a0c91d0002ea1e4fa9264c21ef17 \
|
||||
--hash=sha256:3f0197b6aa6eb3086ae9e66d6dd86c4d502b6c68b0ec490496348ae8c05ecaef \
|
||||
--hash=sha256:48dba9251060289101343110ab47c0756f66f809bb4d1ddbb6d5c7e7752115c5 \
|
||||
--hash=sha256:4915a41375bdee4db749ecd07d985a0486eb688a6619f713b7bf6fbfd145e960 \
|
||||
--hash=sha256:4c1226a7e2c7105ac31503a9bb97454743f55d88164d6d46bc138051b77f609b \
|
||||
--hash=sha256:4e50ffac74842c1c1018b9b73875a0d0a877c066ab06bf7cccbaa84af97e754f \
|
||||
--hash=sha256:518f1f4ed35dd69ce06b552f84e6d081f07c552b4c661c5312d950a0b764a58a \
|
||||
--hash=sha256:5aad740b4d4fcbaaae4887823925166ffd062db3b248b3f432198fc287381d1a \
|
||||
--hash=sha256:5f272186e03ad55e7af09ab78055535c201b1a0bcc2944edb1768298d9c483a4 \
|
||||
--hash=sha256:5fcfc0dc2761e4fcc15ad5d273b4d58c2e8e059d3214a7390d4d3c8e2aee644e \
|
||||
--hash=sha256:60db20f06c3d4c5934b16cfa62a2cc5c3f0686bffe0071ed7804d3c31ab1a04e \
|
||||
--hash=sha256:615a8ac9dda265e9cc38b2a76c3142e4a9f30fea4a79c85f670850783bc6feb4 \
|
||||
--hash=sha256:6482db9876c68faac2d20a96b566ffbf65ddaadd97b222e4e73641f4f8722fc4 \
|
||||
--hash=sha256:6617fb10f9e4393b331941861d73afb119cd847e88e4974bdbe8068ceef3f73f \
|
||||
--hash=sha256:676919fba7311125244eb0c4393679ac5fe856e5864a15d122bd815205369fa0 \
|
||||
--hash=sha256:6c2d2bc8129707e34c51f9352c4636ca313b52350bbb7e04637c46c1818a2a70 \
|
||||
--hash=sha256:71390dbd3fbf6ebea9a5d85ffed8c26ee1453ee09248e9b88486e30e0397b775 \
|
||||
--hash=sha256:716cdbfc57bfd3d3e31a58e6246e8190e6849b7dbb7c4ce39ef8bbf0edb8f6d5 \
|
||||
--hash=sha256:75a26a2307b10745a83b660c404416e984ee6fca515ec7f0765f69af3ce08072 \
|
||||
--hash=sha256:7be5cc6732eb7b4df17305d8a7b293223f934a31783a874a01164703bc1be6cd \
|
||||
--hash=sha256:7cce242b5df12b2b172489daf19c32e5577dd2fac659eb4b17f6a6efb446fd5c \
|
||||
--hash=sha256:81c341d9bb87a6dbbb0d45d6e272aca80c7c97b4b210f9b6e233bf8b87242f29 \
|
||||
--hash=sha256:89899641d4de97dbad8e0cde690040d078b6aea04066dacaab98e0b5a23573f2 \
|
||||
--hash=sha256:8d5ab297d660b75c159190ce6d73035502310e40fd35170aed7d1a1aea7ddd65 \
|
||||
--hash=sha256:8fbe5bcf10d01aab3513550f284c09fef32f342b36f56bfae2120a9c4d12c130 \
|
||||
--hash=sha256:91a2327a4d7e77471fa4fbb26991c6de4a738c6fc6a33e09bb25f56a870a4b7b \
|
||||
--hash=sha256:95a260cafd56b8fffa679918937401c80bb38e1681c448b988022e4c3610965d \
|
||||
--hash=sha256:96484dc0f48be1c5d7ae9f38ed1ce41e3675fd506b27c11a6607f14b49101e99 \
|
||||
--hash=sha256:9a6aec38a989bad7ddd1ef53f1265699e49e294d08231b5313d61293f3cd6237 \
|
||||
--hash=sha256:9ba214f4f45bec195ee8559651154d3ac2932470b9d91c5715fc29c013349f8c \
|
||||
--hash=sha256:9f4a7ec2770e6af05f5a02733fd3900f30a9cd58e5d6d3727e14c5bcd6e7d587 \
|
||||
--hash=sha256:a1cf720896d2ce998bc8e051d4b4ce0d8bec007aab6243102e8e1d22a0b2fb3f \
|
||||
--hash=sha256:a241a68581d34d67b40c425cce3d1fd211c092f99d9250947824ccba9f491949 \
|
||||
--hash=sha256:a53b18797cdef27e019db595d66c4b077325afe2fd62145953275f53d84ce40c \
|
||||
--hash=sha256:a82fc2dbebe6eb908b9c665e71496f8525c1bc4d2e3a7a7722ef2b128b6227c8 \
|
||||
--hash=sha256:a86eb88e06bd87e1fff31dac878965c26b0c26db59ddcf78bb0379a954b120de \
|
||||
--hash=sha256:aa588b21044f8a74e423d8c8a4c7fc9988501878aacced793467010039c50734 \
|
||||
--hash=sha256:b05296e8bc88c92e2b21e0a9bae4740c1551ee613c1d93a51fd28a7a0b2b6fbb \
|
||||
--hash=sha256:b0ec13f352ea5ae0fc91f98a48540512eed0767d0ec4f7f3cb92d92797983d18 \
|
||||
--hash=sha256:b3df42f52502438ee973042cc551877d24619fa1cd38ef7b7e9ac74200daca8b \
|
||||
--hash=sha256:b78008a69300d929ca2efeffec951b64a312e9a811e265ea4a907ab546d79fa6 \
|
||||
--hash=sha256:b9026a21b6d41eb0e2e63f5bc1242c3fcc43ecb770963cda99a4307863dac12e \
|
||||
--hash=sha256:bbe429fc50686bb2a2608a30843e36fbaa123462a5284f136c7d9e0145220bfd \
|
||||
--hash=sha256:bfa1eb759e07d8b7aa7a310a2bc535e127ee70addf90dc8d4b946b593c3e51a8 \
|
||||
--hash=sha256:c1e0ed5d84ffa2d677cc9582fc01e61dab2e7ef8b8996e055f0a76167b1b94df \
|
||||
--hash=sha256:c4278d1873ce6e803e5d4f8d702fd3026bd67fca744aa98881324d1157ddf748 \
|
||||
--hash=sha256:cac2b37ab21c2b36a10b685b1893ebd6b0f83ae26004838ac817680881576567 \
|
||||
--hash=sha256:cbe6df25807227519debd1a57ab236f5f6bad441500e85b13903e51f93a43214 \
|
||||
--hash=sha256:cd2c002f160502608dcc822ed2441a0f4509c52e86fcfd1a09e937278ed1ca14 \
|
||||
--hash=sha256:e0137dd64a493ba6a4be37405cfd6febe650a98cc1e9dca8f6b8c63b1db11b41 \
|
||||
--hash=sha256:e63d558847166543c2c9789e6f985400a520b7eacc4b99181668b2c3aeadd352 \
|
||||
--hash=sha256:eb45a34f23da4f8bc712b6376ca5396914b0b7c09adbb001dad964eb7f3132f8 \
|
||||
--hash=sha256:ecb7572df5372abce8073df078207d9d1749f20b8b136089916a4a0868d56051 \
|
||||
--hash=sha256:f12000a6accdd4bf0a3fa6eaa1b1c7a7bc80af0a2edf3f89d770d3dcce1d0e22 \
|
||||
--hash=sha256:f7d69c1a7168ad0e9cb864e8663acb232986a0c9c9cb9801f56bf6214f53a54d \
|
||||
--hash=sha256:f815fcc2b2a457977724bad97fb4854022980f51ce7b136925e336b530545ae1 \
|
||||
--hash=sha256:fc39f5c27f962ec8660d8d20c24762431131b5d8c672b44b0a54cf2b5bcde9b9
|
||||
zopfli==0.4.0 \
|
||||
--hash=sha256:03181d48e719fcb6cf8340189c61e8f9883d8bbbdf76bf5212a74457f7d083c1 \
|
||||
--hash=sha256:18b5f1570f64d4988482e4466f10ef5f2a30f687c19ad62a64560f2152dc89eb \
|
||||
--hash=sha256:25e4863b8dc30e5d5309f87c106b0b7d3da4ed0e340b8a52b36d4471e797589f \
|
||||
--hash=sha256:7d66337be6d5613dec55213e9ac28f378c41e2cc04fbad4a10748e4df774ca85 \
|
||||
--hash=sha256:9097e8e1dfdb7f5aea5464e469946857e80502b6d29ba1b232450916bd4a74d1 \
|
||||
--hash=sha256:a8ee992b2549e090cd3f0178bf606dd41a29e0613a04cdf5054224662c72dce6 \
|
||||
--hash=sha256:b72a010d205d00b2855acc2302772067362f9ab5a012e3550662aec60d28e6b3 \
|
||||
--hash=sha256:b8bdb41fbfdc4738b7bdc09ed7c1e951579fae192391a5e694d59bb186cdbec7 \
|
||||
--hash=sha256:c3ba02a9a6ca90481d2b2f68bab038b310d63a1e3b5ae305e95a6599787ed941 \
|
||||
--hash=sha256:d1b98ad47c434ef213444a03ef2f826eeec100144d64f6a57504b9893d3931ce \
|
||||
--hash=sha256:f67d04280065e24cb9a4174cb6b3d1f763687f8cb2963aa135ad8f57c6995f5a \
|
||||
--hash=sha256:f94e4dd7d76b4fe9f5d9229372be20d7f786164eea5152d1af1c34298c3d5975
|
||||
# via fonttools
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
pip==25.3 \
|
||||
--hash=sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343 \
|
||||
--hash=sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd
|
||||
pip==26.0 \
|
||||
--hash=sha256:3ce220a0a17915972fbf1ab451baae1521c4539e778b28127efa79b974aff0fa \
|
||||
--hash=sha256:98436feffb9e31bc9339cf369fd55d3331b1580b6a6f1173bacacddcf9c34754
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
setuptools==80.9.0 \
|
||||
--hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
|
||||
--hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
|
||||
setuptools==80.10.2 \
|
||||
--hash=sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70 \
|
||||
--hash=sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
|
||||
@@ -12,25 +12,25 @@ distlib==0.4.0 \
|
||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
||||
# via virtualenv
|
||||
filelock==3.20.0 \
|
||||
--hash=sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2 \
|
||||
--hash=sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4
|
||||
filelock==3.20.3 \
|
||||
--hash=sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1 \
|
||||
--hash=sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1
|
||||
# via virtualenv
|
||||
identify==2.6.15 \
|
||||
--hash=sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757 \
|
||||
--hash=sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf
|
||||
identify==2.6.16 \
|
||||
--hash=sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0 \
|
||||
--hash=sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980
|
||||
# via pre-commit
|
||||
nodeenv==1.9.1 \
|
||||
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
|
||||
--hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
|
||||
nodeenv==1.10.0 \
|
||||
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
|
||||
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
|
||||
# via pre-commit
|
||||
platformdirs==4.5.0 \
|
||||
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
|
||||
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
|
||||
platformdirs==4.5.1 \
|
||||
--hash=sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda \
|
||||
--hash=sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31
|
||||
# via virtualenv
|
||||
pre-commit==4.5.0 \
|
||||
--hash=sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1 \
|
||||
--hash=sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b
|
||||
pre-commit==4.5.1 \
|
||||
--hash=sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77 \
|
||||
--hash=sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61
|
||||
# via -r .github/scripts/requirements_pre_commit.in
|
||||
pyyaml==6.0.3 \
|
||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||
@@ -107,7 +107,7 @@ pyyaml==6.0.3 \
|
||||
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
|
||||
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
|
||||
# via pre-commit
|
||||
virtualenv==20.35.4 \
|
||||
--hash=sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c \
|
||||
--hash=sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b
|
||||
virtualenv==20.36.1 \
|
||||
--hash=sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f \
|
||||
--hash=sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba
|
||||
# via pre-commit
|
||||
|
||||
@@ -8,7 +8,7 @@ tomli-w==1.2.0 \
|
||||
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
|
||||
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
|
||||
# via -r .github/scripts/requirements_sync_readme.in
|
||||
tomlkit==0.13.3 \
|
||||
--hash=sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1 \
|
||||
--hash=sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0
|
||||
tomlkit==0.14.0 \
|
||||
--hash=sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680 \
|
||||
--hash=sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064
|
||||
# via -r .github/scripts/requirements_sync_readme.in
|
||||
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -185,14 +185,14 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
@@ -291,7 +291,7 @@ jobs:
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
uses: digicert/ssm-code-signing@9476ceec3ea1c63298d4403b983e1ccf2556ff4c # v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
@@ -399,7 +399,7 @@ jobs:
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
uses: tauri-apps/tauri-action@51a9f1156b33df106d827c3a78f8f894946c5faa # v0.5.25
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -33,6 +33,11 @@ jobs:
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
@@ -81,14 +86,14 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
@@ -188,7 +193,7 @@ jobs:
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
uses: digicert/ssm-code-signing@9476ceec3ea1c63298d4403b983e1ccf2556ff4c # v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
@@ -307,7 +312,7 @@ jobs:
|
||||
ls -la /usr/bin/hd* || echo "No hd* tools found"
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
uses: tauri-apps/tauri-action@51a9f1156b33df106d827c3a78f8f894946c5faa # v0.5.25
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -639,6 +644,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Report build results
|
||||
run: |
|
||||
if [ "${{ needs.build.result }}" = "success" ]; then
|
||||
|
||||
@@ -128,6 +128,11 @@ jobs:
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Check for file changes
|
||||
|
||||
+1
-1
@@ -213,7 +213,7 @@ id_ed25519.pub
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.ipynb_checkpoints
|
||||
|
||||
.build-cache
|
||||
|
||||
|
||||
**/jcef-bundle/
|
||||
|
||||
@@ -199,7 +199,6 @@ return useToolOperation({
|
||||
|
||||
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
|
||||
- **Lombok**: Used extensively - ensure IDE plugin is installed
|
||||
- **Desktop Mode**: Set `STIRLING_PDF_DESKTOP_UI=true` for desktop application mode
|
||||
- **File Persistence**:
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
|
||||
|
||||
@@ -41,8 +41,8 @@ dependencies {
|
||||
api "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:2.10'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14"
|
||||
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.15"
|
||||
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
|
||||
api 'org.simplejavamail:simple-java-mail:8.12.6'
|
||||
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
|
||||
|
||||
@@ -303,22 +303,23 @@ public class EndpointConfiguration {
|
||||
// Adding endpoints to "PageOps" group
|
||||
addEndpointToGroup("PageOps", "remove-pages");
|
||||
addEndpointToGroup("PageOps", "merge-pdfs");
|
||||
addEndpointToGroup("PageOps", "split-pdfs");
|
||||
addEndpointToGroup("PageOps", "pdf-organizer");
|
||||
addEndpointToGroup("PageOps", "split-pages");
|
||||
addEndpointToGroup("PageOps", "rearrange-pages");
|
||||
addEndpointToGroup("PageOps", "rotate-pdf");
|
||||
addEndpointToGroup("PageOps", "multi-page-layout");
|
||||
addEndpointToGroup("PageOps", "booklet-imposition");
|
||||
addEndpointToGroup("PageOps", "scale-pages");
|
||||
addEndpointToGroup("PageOps", "crop");
|
||||
addEndpointToGroup("PageOps", "extract-page");
|
||||
addEndpointToGroup("PageOps", "pdf-to-single-page");
|
||||
addEndpointToGroup("PageOps", "auto-split-pdf");
|
||||
addEndpointToGroup("PageOps", "split-by-size-or-count");
|
||||
addEndpointToGroup("PageOps", "overlay-pdf");
|
||||
addEndpointToGroup("PageOps", "split-pdf-by-sections");
|
||||
addEndpointToGroup("PageOps", "split-pdf-by-chapters");
|
||||
addEndpointToGroup("PageOps", "add-page-numbers");
|
||||
addEndpointToGroup("PageOps", "extract-pages");
|
||||
|
||||
// Adding endpoints to "Convert" group
|
||||
// Adding endpoints to "Convert" group (Frontend has 15 convert endpoints)
|
||||
addEndpointToGroup("Convert", "pdf-to-img");
|
||||
addEndpointToGroup("Convert", "img-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-pdfa");
|
||||
@@ -334,6 +335,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Convert", "pdf-to-csv");
|
||||
addEndpointToGroup("Convert", "pdf-to-markdown");
|
||||
addEndpointToGroup("Convert", "eml-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-epub");
|
||||
// Backend-only endpoints (not in frontend tool registry)
|
||||
addEndpointToGroup("Convert", "pdf-to-vector");
|
||||
addEndpointToGroup("Convert", "vector-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-video");
|
||||
@@ -341,6 +344,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Convert", "pdf-to-cbz");
|
||||
addEndpointToGroup("Convert", "pdf-to-json");
|
||||
addEndpointToGroup("Convert", "json-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-rtf");
|
||||
|
||||
// Adding endpoints to "Security" group
|
||||
addEndpointToGroup("Security", "add-password");
|
||||
@@ -351,51 +355,49 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Security", "remove-cert-sign");
|
||||
addEndpointToGroup("Security", "sanitize-pdf");
|
||||
addEndpointToGroup("Security", "auto-redact");
|
||||
addEndpointToGroup("Security", "redact");
|
||||
addEndpointToGroup("Security", "validate-signature");
|
||||
addEndpointToGroup("Security", "add-stamp");
|
||||
addEndpointToGroup("Security", "unlock-pdf-forms");
|
||||
// Backend-only endpoints (not in frontend tool registry endpoints)
|
||||
addEndpointToGroup("Security", "redact");
|
||||
addEndpointToGroup("Security", "verify-pdf");
|
||||
addEndpointToGroup("Security", "stamp");
|
||||
addEndpointToGroup("Security", "sign");
|
||||
|
||||
// Adding endpoints to "Other" group
|
||||
addEndpointToGroup("Other", "ocr-pdf");
|
||||
addEndpointToGroup("Other", "add-image");
|
||||
addEndpointToGroup("Other", "extract-images");
|
||||
addEndpointToGroup("Other", "change-metadata");
|
||||
addEndpointToGroup("Other", "update-metadata");
|
||||
addEndpointToGroup("Other", "flatten");
|
||||
addEndpointToGroup("Other", "unlock-pdf-forms");
|
||||
addEndpointToGroup("Other", REMOVE_BLANKS);
|
||||
addEndpointToGroup("Other", "remove-annotations");
|
||||
addEndpointToGroup("Other", "compare");
|
||||
addEndpointToGroup("Other", "add-page-numbers");
|
||||
addEndpointToGroup("Other", "get-info-on-pdf");
|
||||
addEndpointToGroup("Other", "remove-image-pdf");
|
||||
addEndpointToGroup("Other", "add-attachments");
|
||||
addEndpointToGroup("Other", "replace-invert-pdf");
|
||||
addEndpointToGroup("Other", "edit-table-of-contents");
|
||||
addEndpointToGroup("Other", "text-editor-pdf");
|
||||
// Backend-only endpoints (not in frontend tool registry endpoints)
|
||||
addEndpointToGroup("Other", "add-image");
|
||||
addEndpointToGroup("Other", "compare");
|
||||
addEndpointToGroup("Other", "view-pdf");
|
||||
addEndpointToGroup("Other", "replace-and-invert-color-pdf");
|
||||
addEndpointToGroup("Other", "multi-tool");
|
||||
|
||||
// Adding form-related endpoints to "Other" group
|
||||
addEndpointToGroup("Other", "fields");
|
||||
addEndpointToGroup("Other", "modify-fields");
|
||||
addEndpointToGroup("Other", "delete-fields");
|
||||
addEndpointToGroup("Other", "fill");
|
||||
|
||||
// Adding endpoints to "Advance" group
|
||||
addEndpointToGroup("Advance", "adjust-contrast");
|
||||
addEndpointToGroup("Advance", "compress-pdf");
|
||||
addEndpointToGroup("Advance", "extract-image-scans");
|
||||
addEndpointToGroup("Advance", "repair");
|
||||
addEndpointToGroup("Advance", "auto-rename");
|
||||
addEndpointToGroup("Advance", "pipeline");
|
||||
addEndpointToGroup("Advance", "handleData");
|
||||
addEndpointToGroup("Advance", "scanner-effect");
|
||||
addEndpointToGroup("Advance", "auto-split-pdf");
|
||||
addEndpointToGroup("Advance", "show-javascript");
|
||||
addEndpointToGroup("Advance", "split-by-size-or-count");
|
||||
addEndpointToGroup("Advance", "overlay-pdf");
|
||||
addEndpointToGroup("Advance", "split-pdf-by-sections");
|
||||
addEndpointToGroup("Advance", "edit-table-of-contents");
|
||||
addEndpointToGroup("Advance", "split-pdf-by-chapters");
|
||||
// Backend-only endpoints
|
||||
addEndpointToGroup("Advance", "adjust-contrast");
|
||||
addEndpointToGroup("Advance", "pipeline");
|
||||
|
||||
// CLI
|
||||
addEndpointToGroup("CLI", "compress-pdf");
|
||||
@@ -436,8 +438,8 @@ public class EndpointConfiguration {
|
||||
// Java
|
||||
addEndpointToGroup("Java", "merge-pdfs");
|
||||
addEndpointToGroup("Java", "remove-pages");
|
||||
addEndpointToGroup("Java", "split-pdfs");
|
||||
addEndpointToGroup("Java", "pdf-organizer");
|
||||
addEndpointToGroup("Java", "split-pages");
|
||||
addEndpointToGroup("Java", "rearrange-pages");
|
||||
addEndpointToGroup("Java", "rotate-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-img");
|
||||
addEndpointToGroup("Java", "img-to-pdf");
|
||||
@@ -445,9 +447,10 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "remove-password");
|
||||
addEndpointToGroup("Java", "change-permissions");
|
||||
addEndpointToGroup("Java", "add-watermark");
|
||||
addEndpointToGroup("Java", "add-stamp");
|
||||
addEndpointToGroup("Java", "add-image");
|
||||
addEndpointToGroup("Java", "extract-images");
|
||||
addEndpointToGroup("Java", "change-metadata");
|
||||
addEndpointToGroup("Java", "update-metadata");
|
||||
addEndpointToGroup("Java", "cert-sign");
|
||||
addEndpointToGroup("Java", "remove-cert-sign");
|
||||
addEndpointToGroup("Java", "multi-page-layout");
|
||||
@@ -459,7 +462,6 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "sanitize-pdf");
|
||||
addEndpointToGroup("Java", "crop");
|
||||
addEndpointToGroup("Java", "get-info-on-pdf");
|
||||
addEndpointToGroup("Java", "extract-page");
|
||||
addEndpointToGroup("Java", "pdf-to-single-page");
|
||||
addEndpointToGroup("Java", "markdown-to-pdf");
|
||||
addEndpointToGroup("Java", "show-javascript");
|
||||
@@ -469,7 +471,9 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "split-by-size-or-count");
|
||||
addEndpointToGroup("Java", "overlay-pdf");
|
||||
addEndpointToGroup("Java", "split-pdf-by-sections");
|
||||
addEndpointToGroup("Java", "split-pdf-by-chapters");
|
||||
addEndpointToGroup("Java", REMOVE_BLANKS);
|
||||
addEndpointToGroup("Java", "remove-annotations");
|
||||
addEndpointToGroup("Java", "pdf-to-text");
|
||||
addEndpointToGroup("Java", "remove-image-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-markdown");
|
||||
@@ -479,15 +483,24 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "pdf-to-cbz");
|
||||
addEndpointToGroup("Java", "pdf-to-json");
|
||||
addEndpointToGroup("Java", "json-to-pdf");
|
||||
addEndpointToGroup("rar", "pdf-to-cbr");
|
||||
addEndpointToGroup("Java", "pdf-to-video");
|
||||
addEndpointToGroup("Java", "verify-pdf");
|
||||
addEndpointToGroup("Java", "flatten");
|
||||
addEndpointToGroup("Java", "unlock-pdf-forms");
|
||||
addEndpointToGroup("Java", "validate-signature");
|
||||
addEndpointToGroup("Java", "text-editor-pdf");
|
||||
addEndpointToGroup("Java", "edit-table-of-contents");
|
||||
addEndpointToGroup("Java", "pdf-to-epub");
|
||||
addEndpointToGroup("Java", "eml-to-pdf");
|
||||
addEndpointToGroup("Java", "handleData");
|
||||
addEndpointToGroup("rar", "pdf-to-cbr");
|
||||
|
||||
// Javascript
|
||||
addEndpointToGroup("Javascript", "pdf-organizer");
|
||||
addEndpointToGroup("Javascript", "rearrange-pages");
|
||||
addEndpointToGroup("Javascript", "sign");
|
||||
addEndpointToGroup("Javascript", "compare");
|
||||
addEndpointToGroup("Javascript", "adjust-contrast");
|
||||
addEndpointToGroup("Javascript", "text-editor-pdf");
|
||||
|
||||
/* qpdf */
|
||||
addEndpointToGroup("qpdf", "repair");
|
||||
@@ -498,6 +511,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Ghostscript", "compress-pdf");
|
||||
addEndpointToGroup("Ghostscript", "crop");
|
||||
addEndpointToGroup("Ghostscript", "replace-invert-pdf");
|
||||
addEndpointToGroup("Ghostscript", "scanner-effect");
|
||||
addEndpointToGroup("Ghostscript", "pdf-to-vector");
|
||||
addEndpointToGroup("Ghostscript", "vector-to-pdf");
|
||||
|
||||
@@ -516,6 +530,8 @@ public class EndpointConfiguration {
|
||||
addEndpointAlternative("compress-pdf", "qpdf");
|
||||
addEndpointAlternative("compress-pdf", "Ghostscript");
|
||||
addEndpointAlternative("compress-pdf", "Java");
|
||||
addEndpointAlternative("crop", "Ghostscript");
|
||||
addEndpointAlternative("crop", "Java");
|
||||
addEndpointAlternative("ocr-pdf", "tesseract");
|
||||
addEndpointAlternative("ocr-pdf", "OCRmyPDF");
|
||||
|
||||
@@ -544,6 +560,9 @@ public class EndpointConfiguration {
|
||||
// Pdftohtml dependent endpoints
|
||||
addEndpointToGroup("Pdftohtml", "pdf-to-html");
|
||||
addEndpointToGroup("Pdftohtml", "pdf-to-markdown");
|
||||
|
||||
// Calibre dependent endpoints
|
||||
addEndpointToGroup("Calibre", "pdf-to-epub");
|
||||
}
|
||||
|
||||
private void processEnvironmentConfigs() {
|
||||
|
||||
-24
@@ -1,8 +1,6 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Locale;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -61,28 +59,6 @@ public class InstallationPathConfig {
|
||||
}
|
||||
|
||||
private static String initializeBasePath() {
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
|
||||
String os = System.getProperty("os.name").toLowerCase(Locale.ROOT);
|
||||
if (os.contains("win")) {
|
||||
return Paths.get(
|
||||
System.getenv("APPDATA"), // parent path
|
||||
"Stirling-PDF")
|
||||
+ File.separator;
|
||||
} else if (os.contains("mac")) {
|
||||
return Paths.get(
|
||||
System.getProperty("user.home"),
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Stirling-PDF")
|
||||
+ File.separator;
|
||||
} else {
|
||||
return Paths.get(
|
||||
System.getProperty("user.home"), // parent path
|
||||
".config",
|
||||
"Stirling-PDF")
|
||||
+ File.separator;
|
||||
}
|
||||
}
|
||||
return "." + File.separator;
|
||||
}
|
||||
|
||||
|
||||
+155
-6
@@ -1,10 +1,14 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -41,6 +45,7 @@ public class RuntimePathConfig {
|
||||
|
||||
// Pipeline paths
|
||||
private final String pipelineWatchedFoldersPath;
|
||||
private final List<String> pipelineWatchedFoldersPaths;
|
||||
private final String pipelineFinishedFoldersPath;
|
||||
private final String pipelineDefaultWebUiConfigs;
|
||||
private final String pipelinePath;
|
||||
@@ -49,20 +54,27 @@ public class RuntimePathConfig {
|
||||
this.properties = properties;
|
||||
this.basePath = InstallationPathConfig.getPath();
|
||||
|
||||
this.pipelinePath = Path.of(basePath, "pipeline").toString();
|
||||
String defaultWatchedFolders = Path.of(this.pipelinePath, "watchedFolders").toString();
|
||||
String defaultFinishedFolders = Path.of(this.pipelinePath, "finishedFolders").toString();
|
||||
String defaultWebUIConfigs = Path.of(this.pipelinePath, "defaultWebUIConfigs").toString();
|
||||
|
||||
System system = properties.getSystem();
|
||||
CustomPaths customPaths = system.getCustomPaths();
|
||||
|
||||
Pipeline pipeline = customPaths.getPipeline();
|
||||
|
||||
this.pipelineWatchedFoldersPath =
|
||||
this.pipelinePath =
|
||||
resolvePath(
|
||||
Path.of(basePath, "pipeline").toString(),
|
||||
pipeline != null ? pipeline.getPipelineDir() : null);
|
||||
String defaultWatchedFolders = Path.of(this.pipelinePath, "watchedFolders").toString();
|
||||
String defaultFinishedFolders = Path.of(this.pipelinePath, "finishedFolders").toString();
|
||||
String defaultWebUIConfigs = Path.of(this.pipelinePath, "defaultWebUIConfigs").toString();
|
||||
|
||||
List<String> watchedFoldersDirs =
|
||||
sanitizePathList(pipeline != null ? pipeline.getWatchedFoldersDirs() : null);
|
||||
this.pipelineWatchedFoldersPaths =
|
||||
resolveWatchedFolderPaths(
|
||||
defaultWatchedFolders,
|
||||
watchedFoldersDirs,
|
||||
pipeline != null ? pipeline.getWatchedFoldersDir() : null);
|
||||
this.pipelineWatchedFoldersPath = this.pipelineWatchedFoldersPaths.get(0);
|
||||
this.pipelineFinishedFoldersPath =
|
||||
resolvePath(
|
||||
defaultFinishedFolders,
|
||||
@@ -72,6 +84,9 @@ public class RuntimePathConfig {
|
||||
defaultWebUIConfigs,
|
||||
pipeline != null ? pipeline.getWebUIConfigsDir() : null);
|
||||
|
||||
// Validate path conflicts after all paths are resolved
|
||||
validatePipelinePaths();
|
||||
|
||||
boolean isDocker = isRunningInDocker();
|
||||
|
||||
// Initialize Operation paths
|
||||
@@ -129,6 +144,140 @@ public class RuntimePathConfig {
|
||||
return StringUtils.isNotBlank(customPath) ? customPath : defaultPath;
|
||||
}
|
||||
|
||||
private List<String> resolveWatchedFolderPaths(
|
||||
String defaultPath, List<String> watchedFoldersDirs, String legacyWatchedFolder) {
|
||||
List<String> rawPaths = new ArrayList<>();
|
||||
|
||||
// Collect paths from new config
|
||||
if (watchedFoldersDirs != null && !watchedFoldersDirs.isEmpty()) {
|
||||
rawPaths.addAll(watchedFoldersDirs);
|
||||
}
|
||||
// Fall back to legacy config
|
||||
else if (StringUtils.isNotBlank(legacyWatchedFolder)) {
|
||||
rawPaths.add(legacyWatchedFolder);
|
||||
}
|
||||
// Fall back to default
|
||||
else {
|
||||
rawPaths.add(defaultPath);
|
||||
}
|
||||
|
||||
// Validate, normalize, and deduplicate paths
|
||||
List<String> validatedPaths = validateAndNormalizePaths(rawPaths);
|
||||
|
||||
// Ensure we have at least one valid path (critical for system to function)
|
||||
if (validatedPaths.isEmpty()) {
|
||||
log.warn(
|
||||
"No valid watched folder paths configured, falling back to default: {}",
|
||||
defaultPath);
|
||||
validatedPaths.add(defaultPath);
|
||||
}
|
||||
|
||||
// Detect overlapping paths (warning only, not blocking)
|
||||
detectOverlappingPaths(validatedPaths);
|
||||
|
||||
return validatedPaths;
|
||||
}
|
||||
|
||||
private List<String> sanitizePathList(List<String> paths) {
|
||||
if (paths == null || paths.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> sanitized = new ArrayList<>();
|
||||
for (String path : paths) {
|
||||
if (StringUtils.isNotBlank(path)) {
|
||||
sanitized.add(path.trim());
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private List<String> validateAndNormalizePaths(List<String> paths) {
|
||||
Set<String> normalizedPaths = new LinkedHashSet<>(); // Preserves order, prevents duplicates
|
||||
|
||||
for (String pathStr : paths) {
|
||||
if (StringUtils.isBlank(pathStr)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Normalize to absolute path
|
||||
Path path = Paths.get(pathStr.trim()).toAbsolutePath().normalize();
|
||||
String normalizedPath = path.toString();
|
||||
|
||||
// Check for duplicates
|
||||
if (normalizedPaths.contains(normalizedPath)) {
|
||||
log.debug("Skipping duplicate watched folder path: {}", pathStr);
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedPaths.add(normalizedPath);
|
||||
log.info("Registered watched folder path: {}", normalizedPath);
|
||||
|
||||
} catch (InvalidPathException e) {
|
||||
log.error(
|
||||
"Invalid watched folder path '{}' - skipping: {}", pathStr, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return new ArrayList<>(normalizedPaths);
|
||||
}
|
||||
|
||||
private void detectOverlappingPaths(List<String> paths) {
|
||||
for (int i = 0; i < paths.size(); i++) {
|
||||
Path path1 = Paths.get(paths.get(i));
|
||||
for (int j = i + 1; j < paths.size(); j++) {
|
||||
Path path2 = Paths.get(paths.get(j));
|
||||
|
||||
// Check if one path is a parent of the other
|
||||
if (path1.startsWith(path2)) {
|
||||
log.warn(
|
||||
"Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing",
|
||||
path1,
|
||||
path2);
|
||||
} else if (path2.startsWith(path1)) {
|
||||
log.warn(
|
||||
"Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing",
|
||||
path2,
|
||||
path1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePipelinePaths() {
|
||||
try {
|
||||
Path finishedPath = Paths.get(pipelineFinishedFoldersPath).toAbsolutePath().normalize();
|
||||
|
||||
for (String watchedPathStr : pipelineWatchedFoldersPaths) {
|
||||
Path watchedPath = Paths.get(watchedPathStr).toAbsolutePath().normalize();
|
||||
|
||||
// Check if watched folder is same as finished folder
|
||||
if (watchedPath.equals(finishedPath)) {
|
||||
log.error(
|
||||
"CRITICAL: Watched folder '{}' is the same as finished folder '{}' - this will cause processing loops!",
|
||||
watchedPath,
|
||||
finishedPath);
|
||||
}
|
||||
// Check if watched folder contains finished folder
|
||||
else if (finishedPath.startsWith(watchedPath)) {
|
||||
log.warn(
|
||||
"Finished folder '{}' is nested inside watched folder '{}' - this may cause issues",
|
||||
finishedPath,
|
||||
watchedPath);
|
||||
}
|
||||
// Check if finished folder contains watched folder
|
||||
else if (watchedPath.startsWith(finishedPath)) {
|
||||
log.error(
|
||||
"CRITICAL: Watched folder '{}' is nested inside finished folder '{}' - this will cause processing loops!",
|
||||
watchedPath,
|
||||
finishedPath);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error validating pipeline paths: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isRunningInDocker() {
|
||||
return Files.exists(Path.of("/.dockerenv"));
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ public class ApplicationProperties {
|
||||
private String customGlobalAPIKey;
|
||||
private Jwt jwt = new Jwt();
|
||||
private Validation validation = new Validation();
|
||||
private String xFrameOptions = "DENY";
|
||||
|
||||
public Boolean isAltLogin() {
|
||||
return saml2.getEnabled() || oauth2.getEnabled();
|
||||
@@ -458,7 +459,9 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Pipeline {
|
||||
private String pipelineDir;
|
||||
private String watchedFoldersDir;
|
||||
private List<String> watchedFoldersDirs = new ArrayList<>();
|
||||
private String finishedFoldersDir;
|
||||
private String webUIConfigsDir;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public class FileMonitor {
|
||||
private final ConcurrentHashMap.KeySetView<Path, Boolean> readyForProcessingFiles;
|
||||
private final WatchService watchService;
|
||||
private final Predicate<Path> pathFilter;
|
||||
private final Path rootDir;
|
||||
private final List<Path> rootDirs;
|
||||
private Set<Path> stagingFiles;
|
||||
|
||||
/**
|
||||
@@ -47,8 +47,28 @@ public class FileMonitor {
|
||||
this.pathFilter = pathFilter;
|
||||
this.readyForProcessingFiles = ConcurrentHashMap.newKeySet();
|
||||
this.watchService = FileSystems.getDefault().newWatchService();
|
||||
log.info("Monitoring directory: {}", runtimePathConfig.getPipelineWatchedFoldersPath());
|
||||
this.rootDir = Path.of(runtimePathConfig.getPipelineWatchedFoldersPath());
|
||||
|
||||
List<String> watchedFoldersDirs = runtimePathConfig.getPipelineWatchedFoldersPaths();
|
||||
List<Path> validRootDirs = new ArrayList<>();
|
||||
|
||||
for (String pathStr : watchedFoldersDirs) {
|
||||
try {
|
||||
Path path = Path.of(pathStr);
|
||||
validRootDirs.add(path);
|
||||
log.info("Monitoring directory: {}", path);
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Failed to initialize monitoring for path '{}': {}",
|
||||
pathStr,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
this.rootDirs = Collections.unmodifiableList(validRootDirs);
|
||||
|
||||
if (this.rootDirs.isEmpty()) {
|
||||
log.error("No valid directories to monitor - FileMonitor will not function");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldNotProcess(Path path) {
|
||||
@@ -85,13 +105,15 @@ public class FileMonitor {
|
||||
readyForProcessingFiles.clear();
|
||||
|
||||
if (path2KeyMapping.isEmpty()) {
|
||||
log.warn("not monitoring any directory, even the root directory itself: {}", rootDir);
|
||||
if (Files.exists(
|
||||
rootDir)) { // if the root directory exists, re-register the root directory
|
||||
try {
|
||||
recursivelyRegisterEntry(rootDir);
|
||||
} catch (IOException e) {
|
||||
log.error("unable to register monitoring", e);
|
||||
log.warn("Not monitoring any directories; attempting to re-register root paths.");
|
||||
for (Path rootDir : rootDirs) {
|
||||
if (Files.exists(
|
||||
rootDir)) { // if the root directory exists, re-register the root directory
|
||||
try {
|
||||
recursivelyRegisterEntry(rootDir);
|
||||
} catch (IOException e) {
|
||||
log.error("unable to register monitoring for {}", rootDir, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
@@ -38,6 +40,10 @@ public class GeneralUtils {
|
||||
*/
|
||||
private static final int MAX_DNS_ADDRESSES = 20;
|
||||
|
||||
// Constants for size conversion
|
||||
private static final BigDecimal KIB = BigDecimal.valueOf(1024L);
|
||||
private static final BigDecimal LONG_MAX_DECIMAL = BigDecimal.valueOf(Long.MAX_VALUE);
|
||||
|
||||
private final Set<String> DEFAULT_VALID_SCRIPTS = Set.of("png_to_webp.py", "split_photos.py");
|
||||
private final Set<String> DEFAULT_VALID_PIPELINE =
|
||||
Set.of(
|
||||
@@ -539,39 +545,26 @@ public class GeneralUtils {
|
||||
|
||||
try {
|
||||
if (sizeStr.endsWith("TB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L);
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 4);
|
||||
} else if (sizeStr.endsWith("GB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L);
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 3);
|
||||
} else if (sizeStr.endsWith("MB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024L
|
||||
* 1024L);
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 2);
|
||||
} else if (sizeStr.endsWith("KB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2)) * 1024L);
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 1);
|
||||
} else if (!sizeStr.isEmpty() && sizeStr.charAt(sizeStr.length() - 1) == 'B') {
|
||||
return Long.parseLong(sizeStr.substring(0, sizeStr.length() - 1));
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 1)), 0);
|
||||
} else {
|
||||
// Use provided default unit or fall back to MB
|
||||
String unit = defaultUnit != null ? defaultUnit.toUpperCase(Locale.ROOT) : "MB";
|
||||
double value = Double.parseDouble(sizeStr);
|
||||
BigDecimal value = parseSizeValue(sizeStr);
|
||||
return switch (unit) {
|
||||
case "TB" -> (long) (value * 1024L * 1024L * 1024L * 1024L);
|
||||
case "GB" -> (long) (value * 1024L * 1024L * 1024L);
|
||||
case "MB" -> (long) (value * 1024L * 1024L);
|
||||
case "KB" -> (long) (value * 1024L);
|
||||
case "B" -> (long) value;
|
||||
default -> (long) (value * 1024L * 1024L); // Default to MB
|
||||
case "TB" -> toBytes(value, 4);
|
||||
case "GB" -> toBytes(value, 3);
|
||||
case "MB" -> toBytes(value, 2);
|
||||
case "KB" -> toBytes(value, 1);
|
||||
case "B" -> toBytes(value, 0);
|
||||
default -> toBytes(value, 2); // Default to MB
|
||||
};
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
@@ -590,6 +583,30 @@ public class GeneralUtils {
|
||||
return convertSizeToBytes(sizeStr, "MB");
|
||||
}
|
||||
|
||||
private Long toBytes(BigDecimal value, int powerOf1024) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value.compareTo(BigDecimal.ZERO) < 0) {
|
||||
log.warn("Size value cannot be negative: {}", value);
|
||||
return null;
|
||||
}
|
||||
if (powerOf1024 < 0 || powerOf1024 > 4) {
|
||||
throw new IllegalArgumentException("Invalid power for size conversion: " + powerOf1024);
|
||||
}
|
||||
BigDecimal multiplier = powerOf1024 == 0 ? BigDecimal.ONE : KIB.pow(powerOf1024);
|
||||
BigDecimal bytes = value.multiply(multiplier).setScale(0, RoundingMode.DOWN);
|
||||
if (bytes.compareTo(LONG_MAX_DECIMAL) > 0) {
|
||||
log.warn("Size value too large to fit in long: {}", bytes);
|
||||
return null;
|
||||
}
|
||||
return bytes.longValue();
|
||||
}
|
||||
|
||||
private BigDecimal parseSizeValue(String value) {
|
||||
return new BigDecimal(value);
|
||||
}
|
||||
|
||||
/* Validates if a string represents a valid size unit. */
|
||||
private boolean isValidSizeUnit(String unit) {
|
||||
// Use a precomputed Set for O(1) lookup, normalize using a locale-safe toUpperCase
|
||||
|
||||
@@ -38,12 +38,12 @@ public class RequestUriUtils {
|
||||
}
|
||||
|
||||
// Specific static files bundled with the frontend
|
||||
if (normalizedUri.equals("/robots.txt")
|
||||
|| normalizedUri.equals("/favicon.ico")
|
||||
|| normalizedUri.equals("/manifest.json")
|
||||
|| normalizedUri.equals("/site.webmanifest")
|
||||
|| normalizedUri.equals("/manifest-classic.json")
|
||||
|| normalizedUri.equals("/index.html")) {
|
||||
if ("/robots.txt".equals(normalizedUri)
|
||||
|| "/favicon.ico".equals(normalizedUri)
|
||||
|| "/manifest.json".equals(normalizedUri)
|
||||
|| "/site.webmanifest".equals(normalizedUri)
|
||||
|| "/manifest-classic.json".equals(normalizedUri)
|
||||
|| "/index.html".equals(normalizedUri)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -173,6 +173,12 @@ public class RequestUriUtils {
|
||||
"/api/v1/ui-data/footer-info") // Public footer configuration
|
||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
// Health Endoints
|
||||
|| trimmedUri.startsWith("/actuator/health")
|
||||
|| trimmedUri.startsWith("/health")
|
||||
|| trimmedUri.startsWith("/healthz")
|
||||
|| trimmedUri.startsWith("/liveness")
|
||||
|| trimmedUri.startsWith("/readiness")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|
||||
|| trimmedUri.startsWith("/v1/api-docs");
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -34,7 +35,8 @@ class FileMonitorTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
when(runtimePathConfig.getPipelineWatchedFoldersPath()).thenReturn(tempDir.toString());
|
||||
when(runtimePathConfig.getPipelineWatchedFoldersPaths())
|
||||
.thenReturn(List.of(tempDir.toString()));
|
||||
|
||||
// This mock is used in all tests except testPathFilter
|
||||
// We use lenient to avoid UnnecessaryStubbingException in that test
|
||||
|
||||
@@ -16,6 +16,14 @@ class GeneralUtilsAdditionalTest {
|
||||
assertNull(GeneralUtils.convertSizeToBytes(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConvertSizeToBytesEdgeCases() {
|
||||
assertNull(GeneralUtils.convertSizeToBytes("-10MB"));
|
||||
assertNull(GeneralUtils.convertSizeToBytes("10000000TB")); // overflow beyond long
|
||||
assertEquals(1099511627776L, GeneralUtils.convertSizeToBytes("1TB"));
|
||||
assertEquals(2684354560L, GeneralUtils.convertSizeToBytes("2.5GB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFormatBytes() {
|
||||
assertEquals("512 B", GeneralUtils.formatBytes(512));
|
||||
|
||||
+2
-19
@@ -38,13 +38,6 @@ spotless {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
if (System.getenv('STIRLING_PDF_DESKTOP_UI') != 'false'
|
||||
|| (project.hasProperty('STIRLING_PDF_DESKTOP_UI')
|
||||
&& project.getProperty('STIRLING_PDF_DESKTOP_UI') != 'false')) {
|
||||
implementation 'org.openjfx:javafx-controls:21'
|
||||
implementation 'org.openjfx:javafx-swing:21'
|
||||
}
|
||||
|
||||
if (System.getenv('DISABLE_ADDITIONAL_FEATURES') != 'true'
|
||||
|| (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
|
||||
&& System.getProperty('DISABLE_ADDITIONAL_FEATURES') != 'true')) {
|
||||
@@ -58,7 +51,7 @@ dependencies {
|
||||
implementation 'commons-io:commons-io:2.21.0'
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
|
||||
implementation 'io.micrometer:micrometer-core:1.16.1'
|
||||
implementation 'io.micrometer:micrometer-core:1.16.2'
|
||||
implementation 'com.google.zxing:core:3.5.4'
|
||||
implementation "org.commonmark:commonmark:$commonmarkVersion" // https://mvnrepository.com/artifact/org.commonmark/commonmark
|
||||
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
|
||||
@@ -115,19 +108,8 @@ sourceSets {
|
||||
resources {
|
||||
srcDirs += ['../configs']
|
||||
}
|
||||
java {
|
||||
if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') {
|
||||
exclude 'stirling/software/SPDF/UI/impl/**'
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
test {
|
||||
java {
|
||||
if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') {
|
||||
exclude 'stirling/software/SPDF/UI/impl/**'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -239,6 +221,7 @@ tasks.register('npmBuild', Exec) {
|
||||
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'run', 'build'] : ['npm', 'run', 'build']
|
||||
dependsOn npmInstall
|
||||
inputs.dir(new File(frontendDir, 'src'))
|
||||
inputs.dir(new File(frontendDir, 'public'))
|
||||
inputs.file(new File(frontendDir, 'package.json'))
|
||||
outputs.dir(frontendDistDir)
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import io.github.pixee.security.SystemCommand;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -28,7 +27,6 @@ import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.configuration.ConfigInitializer;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.UrlUtils;
|
||||
|
||||
@Slf4j
|
||||
@EnableScheduling
|
||||
@@ -60,19 +58,6 @@ public class SPDFApplication {
|
||||
|
||||
Properties props = new Properties();
|
||||
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
|
||||
System.setProperty("java.awt.headless", "false");
|
||||
app.setHeadless(false);
|
||||
props.put("java.awt.headless", "false");
|
||||
props.put("spring.main.web-application-type", "servlet");
|
||||
|
||||
int desiredPort = 8080;
|
||||
String port = UrlUtils.findAvailablePort(desiredPort);
|
||||
props.put("server.port", port);
|
||||
System.setProperty("server.port", port);
|
||||
log.info("Desktop UI mode: Using port {}", port);
|
||||
}
|
||||
|
||||
app.setAdditionalProfiles(getActiveProfile(args));
|
||||
|
||||
ConfigInitializer initializer = new ConfigInitializer();
|
||||
@@ -153,13 +138,6 @@ public class SPDFApplication {
|
||||
"Running in Tauri mode. Parent process PID: {}",
|
||||
parentPid != null ? parentPid : "not set");
|
||||
}
|
||||
// Desktop UI initialization removed - webBrowser dependency eliminated
|
||||
// Keep backwards compatibility for STIRLING_PDF_DESKTOP_UI system property
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
|
||||
log.info("Desktop UI mode enabled, but WebBrowser functionality has been removed");
|
||||
// webBrowser.initWebUI(url); // Removed - desktop UI eliminated
|
||||
}
|
||||
|
||||
// Standard browser opening logic
|
||||
String browserOpenEnv = env.getProperty("BROWSER_OPEN");
|
||||
boolean browserOpen = browserOpenEnv != null && "true".equalsIgnoreCase(browserOpenEnv);
|
||||
@@ -192,14 +170,6 @@ public class SPDFApplication {
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void cleanup() {
|
||||
// webBrowser cleanup removed - desktop UI eliminated
|
||||
// if (webBrowser != null) {
|
||||
// webBrowser.cleanup();
|
||||
// }
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void onWebServerInitialized(WebServerInitializedEvent event) {
|
||||
int actualPort = event.getWebServer().getPort();
|
||||
|
||||
@@ -118,7 +118,8 @@ public class ExternalAppDepConfig {
|
||||
|
||||
for (String group : affectedGroups) {
|
||||
List<String> affectedFeatures = getAffectedFeatures(group);
|
||||
endpointConfiguration.disableGroup(group);
|
||||
endpointConfiguration.disableGroup(
|
||||
group, EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
|
||||
command,
|
||||
@@ -143,7 +144,8 @@ public class ExternalAppDepConfig {
|
||||
commandToGroupMapping.getOrDefault(
|
||||
command, List.of("Weasyprint"));
|
||||
for (String group : affectedGroups) {
|
||||
endpointConfiguration.disableGroup(group);
|
||||
endpointConfiguration.disableGroup(
|
||||
group, EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
}
|
||||
log.warn(
|
||||
"WeasyPrint version {} is below required {} - disabling"
|
||||
@@ -172,7 +174,8 @@ public class ExternalAppDepConfig {
|
||||
List<String> affectedGroups =
|
||||
commandToGroupMapping.getOrDefault(command, List.of("qpdf"));
|
||||
for (String group : affectedGroups) {
|
||||
endpointConfiguration.disableGroup(group);
|
||||
endpointConfiguration.disableGroup(
|
||||
group, EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
}
|
||||
log.warn(
|
||||
"qpdf version {} is below required {} - disabling group(s): {}",
|
||||
@@ -226,7 +229,8 @@ public class ExternalAppDepConfig {
|
||||
int ec = runAndWait(List.of(python, "-c", "import cv2"), DEFAULT_TIMEOUT).exitCode();
|
||||
if (ec != 0) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup(
|
||||
"OpenCV", EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"OpenCV not available in Python - Disabling OpenCV features: {}",
|
||||
String.join(", ", openCVFeatures));
|
||||
@@ -236,8 +240,10 @@ public class ExternalAppDepConfig {
|
||||
private void disablePythonAndOpenCV(String reason) {
|
||||
List<String> pythonFeatures = getAffectedFeatures("Python");
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("Python");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup(
|
||||
"Python", EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
endpointConfiguration.disableGroup(
|
||||
"OpenCV", EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: Python (reason: {}) - Disabling Python features: {} and OpenCV"
|
||||
+ " features: {}",
|
||||
|
||||
@@ -106,16 +106,29 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
} else if (hasConfiguredOrigins) {
|
||||
// Use user-configured origins
|
||||
// Use user-configured origins + always include Tauri origins for desktop app support
|
||||
logger.info(
|
||||
"Configuring CORS with allowed origins: {}",
|
||||
applicationProperties.getSystem().getCorsAllowedOrigins());
|
||||
|
||||
String[] allowedOrigins =
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.getCorsAllowedOrigins()
|
||||
.toArray(new String[0]);
|
||||
// Combine user-configured origins with Tauri origins
|
||||
java.util.List<String> allOrigins =
|
||||
new java.util.ArrayList<>(
|
||||
applicationProperties.getSystem().getCorsAllowedOrigins());
|
||||
|
||||
// Always include Tauri origins for desktop app compatibility
|
||||
// Tauri v1 uses tauri://localhost, v2 uses http(s)://tauri.localhost
|
||||
if (!allOrigins.contains("tauri://localhost")) {
|
||||
allOrigins.add("tauri://localhost");
|
||||
}
|
||||
if (!allOrigins.contains("http://tauri.localhost")) {
|
||||
allOrigins.add("http://tauri.localhost");
|
||||
}
|
||||
if (!allOrigins.contains("https://tauri.localhost")) {
|
||||
allOrigins.add("https://tauri.localhost");
|
||||
}
|
||||
|
||||
String[] allowedOrigins = allOrigins.toArray(new String[0]);
|
||||
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns(allowedOrigins)
|
||||
|
||||
@@ -18,26 +18,22 @@ import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.CropPdfForm;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@GeneralApi
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CropController {
|
||||
@@ -122,7 +118,7 @@ public class CropController {
|
||||
return r >= threshold && g >= threshold && b >= threshold;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Crops a PDF document",
|
||||
description =
|
||||
|
||||
+13
-10
@@ -19,7 +19,6 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -27,36 +26,38 @@ import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@GeneralApi
|
||||
@Slf4j
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class EditTableOfContentsController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@PostMapping(value = "/extract-bookmarks", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/extract-bookmarks",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Extract PDF Bookmarks",
|
||||
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
|
||||
@ResponseBody
|
||||
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
|
||||
public ResponseEntity<List<Map<String, Object>>> extractBookmarks(@RequestParam("file") MultipartFile file)
|
||||
throws Exception {
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
PDDocumentOutline outline = document.getDocumentCatalog().getDocumentOutline();
|
||||
|
||||
if (outline == null) {
|
||||
log.info("No outline/bookmarks found in PDF");
|
||||
return new ArrayList<>();
|
||||
return ResponseEntity.ok(new ArrayList<>());
|
||||
}
|
||||
|
||||
return extractBookmarkItems(document, outline);
|
||||
List<Map<String, Object>> bookmarks = extractBookmarkItems(document, outline);
|
||||
return ResponseEntity.ok(bookmarks);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +143,9 @@ public class EditTableOfContentsController {
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/edit-table-of-contents", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/edit-table-of-contents",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Edit Table of Contents",
|
||||
description = "Add or edit bookmarks/table of contents in a PDF document.")
|
||||
|
||||
+6
-8
@@ -14,34 +14,32 @@ import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralFormCopyUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@GeneralApi
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MultiPageLayoutController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(value = "/multi-page-layout", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/multi-page-layout",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Merge multiple pages of a PDF document into a single page",
|
||||
description =
|
||||
|
||||
+4
-8
@@ -15,26 +15,22 @@ import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.ScalePagesRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@GeneralApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ScalePagesController {
|
||||
@@ -116,7 +112,7 @@ public class ScalePagesController {
|
||||
return sizeMap;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Change the size of a PDF page/document",
|
||||
description =
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
@@ -52,385 +49,4 @@ public class SettingsController {
|
||||
public ResponseEntity<Map<String, Boolean>> getDisabledEndpoints() {
|
||||
return ResponseEntity.ok(endpointConfiguration.getEndpointStatuses());
|
||||
}
|
||||
|
||||
// ========== GENERAL SETTINGS ==========
|
||||
|
||||
@GetMapping("/admin/settings/general")
|
||||
@Hidden
|
||||
public ResponseEntity<Map<String, Object>> getGeneralSettings() {
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
settings.put("ui", applicationProperties.getUi());
|
||||
settings.put(
|
||||
"system",
|
||||
Map.of(
|
||||
"defaultLocale", applicationProperties.getSystem().getDefaultLocale(),
|
||||
"showUpdate", applicationProperties.getSystem().isShowUpdate(),
|
||||
"showUpdateOnlyAdmin",
|
||||
applicationProperties.getSystem().isShowUpdateOnlyAdmin(),
|
||||
"customHTMLFiles", applicationProperties.getSystem().isCustomHTMLFiles(),
|
||||
"fileUploadLimit", applicationProperties.getSystem().getFileUploadLimit()));
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/settings/general")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updateGeneralSettings(@RequestBody Map<String, Object> settings)
|
||||
throws IOException {
|
||||
// Update UI settings
|
||||
if (settings.containsKey("ui")) {
|
||||
Map<String, String> ui = (Map<String, String>) settings.get("ui");
|
||||
if (ui.containsKey("appNameNavbar")) {
|
||||
GeneralUtils.saveKeyToSettings("ui.appNameNavbar", ui.get("appNameNavbar"));
|
||||
applicationProperties.getUi().setAppNameNavbar(ui.get("appNameNavbar"));
|
||||
}
|
||||
}
|
||||
|
||||
// Update System settings
|
||||
if (settings.containsKey("system")) {
|
||||
Map<String, Object> system = (Map<String, Object>) settings.get("system");
|
||||
if (system.containsKey("defaultLocale")) {
|
||||
GeneralUtils.saveKeyToSettings("system.defaultLocale", system.get("defaultLocale"));
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.setDefaultLocale((String) system.get("defaultLocale"));
|
||||
}
|
||||
if (system.containsKey("showUpdate")) {
|
||||
GeneralUtils.saveKeyToSettings("system.showUpdate", system.get("showUpdate"));
|
||||
applicationProperties.getSystem().setShowUpdate((Boolean) system.get("showUpdate"));
|
||||
}
|
||||
if (system.containsKey("showUpdateOnlyAdmin")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"system.showUpdateOnlyAdmin", system.get("showUpdateOnlyAdmin"));
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.setShowUpdateOnlyAdmin((Boolean) system.get("showUpdateOnlyAdmin"));
|
||||
}
|
||||
if (system.containsKey("fileUploadLimit")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"system.fileUploadLimit", system.get("fileUploadLimit"));
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.setFileUploadLimit((String) system.get("fileUploadLimit"));
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
"General settings updated. Restart required for changes to take effect.");
|
||||
}
|
||||
|
||||
// ========== SECURITY SETTINGS ==========
|
||||
|
||||
@GetMapping("/admin/settings/security")
|
||||
@Hidden
|
||||
public ResponseEntity<Map<String, Object>> getSecuritySettings() {
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
ApplicationProperties.Security security = applicationProperties.getSecurity();
|
||||
|
||||
settings.put("enableLogin", security.isEnableLogin());
|
||||
settings.put("loginMethod", security.getLoginMethod());
|
||||
settings.put("loginAttemptCount", security.getLoginAttemptCount());
|
||||
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
|
||||
settings.put(
|
||||
"initialLogin",
|
||||
Map.of(
|
||||
"username",
|
||||
security.getInitialLogin().getUsername() != null
|
||||
? security.getInitialLogin().getUsername()
|
||||
: ""));
|
||||
|
||||
// JWT settings
|
||||
ApplicationProperties.Security.Jwt jwt = security.getJwt();
|
||||
settings.put(
|
||||
"jwt",
|
||||
Map.of(
|
||||
"enableKeystore", jwt.isEnableKeystore(),
|
||||
"enableKeyRotation", jwt.isEnableKeyRotation(),
|
||||
"enableKeyCleanup", jwt.isEnableKeyCleanup(),
|
||||
"keyRetentionDays", jwt.getKeyRetentionDays()));
|
||||
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/settings/security")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updateSecuritySettings(@RequestBody Map<String, Object> settings)
|
||||
throws IOException {
|
||||
if (settings.containsKey("enableLogin")) {
|
||||
GeneralUtils.saveKeyToSettings("security.enableLogin", settings.get("enableLogin"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.setEnableLogin((Boolean) settings.get("enableLogin"));
|
||||
}
|
||||
if (settings.containsKey("loginMethod")) {
|
||||
GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.setLoginMethod((String) settings.get("loginMethod"));
|
||||
}
|
||||
if (settings.containsKey("loginAttemptCount")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.loginAttemptCount", settings.get("loginAttemptCount"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.setLoginAttemptCount((Integer) settings.get("loginAttemptCount"));
|
||||
}
|
||||
if (settings.containsKey("loginResetTimeMinutes")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.loginResetTimeMinutes", settings.get("loginResetTimeMinutes"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.setLoginResetTimeMinutes(
|
||||
((Number) settings.get("loginResetTimeMinutes")).longValue());
|
||||
}
|
||||
|
||||
// JWT settings
|
||||
if (settings.containsKey("jwt")) {
|
||||
Map<String, Object> jwt = (Map<String, Object>) settings.get("jwt");
|
||||
if (jwt.containsKey("keyRetentionDays")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.jwt.keyRetentionDays", jwt.get("keyRetentionDays"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getJwt()
|
||||
.setKeyRetentionDays((Integer) jwt.get("keyRetentionDays"));
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
"Security settings updated. Restart required for changes to take effect.");
|
||||
}
|
||||
|
||||
// ========== CONNECTIONS SETTINGS (OAuth/SAML) ==========
|
||||
|
||||
@GetMapping("/admin/settings/connections")
|
||||
@Hidden
|
||||
public ResponseEntity<Map<String, Object>> getConnectionsSettings() {
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
ApplicationProperties.Security security = applicationProperties.getSecurity();
|
||||
|
||||
// OAuth2 settings
|
||||
ApplicationProperties.Security.OAUTH2 oauth2 = security.getOauth2();
|
||||
settings.put(
|
||||
"oauth2",
|
||||
Map.of(
|
||||
"enabled", oauth2.getEnabled(),
|
||||
"issuer", oauth2.getIssuer() != null ? oauth2.getIssuer() : "",
|
||||
"clientId", oauth2.getClientId() != null ? oauth2.getClientId() : "",
|
||||
"provider", oauth2.getProvider() != null ? oauth2.getProvider() : "",
|
||||
"autoCreateUser", oauth2.getAutoCreateUser(),
|
||||
"blockRegistration", oauth2.getBlockRegistration(),
|
||||
"useAsUsername",
|
||||
oauth2.getUseAsUsername() != null
|
||||
? oauth2.getUseAsUsername()
|
||||
: ""));
|
||||
|
||||
// SAML2 settings
|
||||
ApplicationProperties.Security.SAML2 saml2 = security.getSaml2();
|
||||
settings.put(
|
||||
"saml2",
|
||||
Map.of(
|
||||
"enabled", saml2.getEnabled(),
|
||||
"provider", saml2.getProvider() != null ? saml2.getProvider() : "",
|
||||
"autoCreateUser", saml2.getAutoCreateUser(),
|
||||
"blockRegistration", saml2.getBlockRegistration(),
|
||||
"registrationId", saml2.getRegistrationId()));
|
||||
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/settings/connections")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updateConnectionsSettings(
|
||||
@RequestBody Map<String, Object> settings) throws IOException {
|
||||
// OAuth2 settings
|
||||
if (settings.containsKey("oauth2")) {
|
||||
Map<String, Object> oauth2 = (Map<String, Object>) settings.get("oauth2");
|
||||
if (oauth2.containsKey("enabled")) {
|
||||
GeneralUtils.saveKeyToSettings("security.oauth2.enabled", oauth2.get("enabled"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setEnabled((Boolean) oauth2.get("enabled"));
|
||||
}
|
||||
if (oauth2.containsKey("issuer")) {
|
||||
GeneralUtils.saveKeyToSettings("security.oauth2.issuer", oauth2.get("issuer"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setIssuer((String) oauth2.get("issuer"));
|
||||
}
|
||||
if (oauth2.containsKey("clientId")) {
|
||||
GeneralUtils.saveKeyToSettings("security.oauth2.clientId", oauth2.get("clientId"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setClientId((String) oauth2.get("clientId"));
|
||||
}
|
||||
if (oauth2.containsKey("clientSecret")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.oauth2.clientSecret", oauth2.get("clientSecret"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setClientSecret((String) oauth2.get("clientSecret"));
|
||||
}
|
||||
if (oauth2.containsKey("provider")) {
|
||||
GeneralUtils.saveKeyToSettings("security.oauth2.provider", oauth2.get("provider"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setProvider((String) oauth2.get("provider"));
|
||||
}
|
||||
if (oauth2.containsKey("autoCreateUser")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.oauth2.autoCreateUser", oauth2.get("autoCreateUser"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setAutoCreateUser((Boolean) oauth2.get("autoCreateUser"));
|
||||
}
|
||||
if (oauth2.containsKey("blockRegistration")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.oauth2.blockRegistration", oauth2.get("blockRegistration"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setBlockRegistration((Boolean) oauth2.get("blockRegistration"));
|
||||
}
|
||||
if (oauth2.containsKey("useAsUsername")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.oauth2.useAsUsername", oauth2.get("useAsUsername"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setUseAsUsername((String) oauth2.get("useAsUsername"));
|
||||
}
|
||||
}
|
||||
|
||||
// SAML2 settings
|
||||
if (settings.containsKey("saml2")) {
|
||||
Map<String, Object> saml2 = (Map<String, Object>) settings.get("saml2");
|
||||
if (saml2.containsKey("enabled")) {
|
||||
GeneralUtils.saveKeyToSettings("security.saml2.enabled", saml2.get("enabled"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getSaml2()
|
||||
.setEnabled((Boolean) saml2.get("enabled"));
|
||||
}
|
||||
if (saml2.containsKey("provider")) {
|
||||
GeneralUtils.saveKeyToSettings("security.saml2.provider", saml2.get("provider"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getSaml2()
|
||||
.setProvider((String) saml2.get("provider"));
|
||||
}
|
||||
if (saml2.containsKey("autoCreateUser")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.saml2.autoCreateUser", saml2.get("autoCreateUser"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getSaml2()
|
||||
.setAutoCreateUser((Boolean) saml2.get("autoCreateUser"));
|
||||
}
|
||||
if (saml2.containsKey("blockRegistration")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.saml2.blockRegistration", saml2.get("blockRegistration"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getSaml2()
|
||||
.setBlockRegistration((Boolean) saml2.get("blockRegistration"));
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
"Connection settings updated. Restart required for changes to take effect.");
|
||||
}
|
||||
|
||||
// ========== PRIVACY SETTINGS ==========
|
||||
|
||||
@GetMapping("/admin/settings/privacy")
|
||||
@Hidden
|
||||
public ResponseEntity<Map<String, Object>> getPrivacySettings() {
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
|
||||
settings.put("enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
|
||||
settings.put("googleVisibility", applicationProperties.getSystem().isGooglevisibility());
|
||||
settings.put("metricsEnabled", applicationProperties.getMetrics().isEnabled());
|
||||
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/settings/privacy")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updatePrivacySettings(@RequestBody Map<String, Object> settings)
|
||||
throws IOException {
|
||||
if (settings.containsKey("enableAnalytics")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"system.enableAnalytics", settings.get("enableAnalytics"));
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.setEnableAnalytics((Boolean) settings.get("enableAnalytics"));
|
||||
}
|
||||
if (settings.containsKey("googleVisibility")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"system.googlevisibility", settings.get("googleVisibility"));
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.setGooglevisibility((Boolean) settings.get("googleVisibility"));
|
||||
}
|
||||
if (settings.containsKey("metricsEnabled")) {
|
||||
GeneralUtils.saveKeyToSettings("metrics.enabled", settings.get("metricsEnabled"));
|
||||
applicationProperties.getMetrics().setEnabled((Boolean) settings.get("metricsEnabled"));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
"Privacy settings updated. Restart required for changes to take effect.");
|
||||
}
|
||||
|
||||
// ========== ADVANCED SETTINGS ==========
|
||||
|
||||
@GetMapping("/admin/settings/advanced")
|
||||
@Hidden
|
||||
public ResponseEntity<Map<String, Object>> getAdvancedSettings() {
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
|
||||
settings.put("endpoints", applicationProperties.getEndpoints());
|
||||
settings.put(
|
||||
"enableAlphaFunctionality",
|
||||
applicationProperties.getSystem().isEnableAlphaFunctionality());
|
||||
settings.put("maxDPI", applicationProperties.getSystem().getMaxDPI());
|
||||
settings.put("enableUrlToPDF", applicationProperties.getSystem().isEnableUrlToPDF());
|
||||
settings.put("customPaths", applicationProperties.getSystem().getCustomPaths());
|
||||
settings.put(
|
||||
"tempFileManagement", applicationProperties.getSystem().getTempFileManagement());
|
||||
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/settings/advanced")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updateAdvancedSettings(@RequestBody Map<String, Object> settings)
|
||||
throws IOException {
|
||||
if (settings.containsKey("enableAlphaFunctionality")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"system.enableAlphaFunctionality", settings.get("enableAlphaFunctionality"));
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.setEnableAlphaFunctionality(
|
||||
(Boolean) settings.get("enableAlphaFunctionality"));
|
||||
}
|
||||
if (settings.containsKey("maxDPI")) {
|
||||
GeneralUtils.saveKeyToSettings("system.maxDPI", settings.get("maxDPI"));
|
||||
applicationProperties.getSystem().setMaxDPI((Integer) settings.get("maxDPI"));
|
||||
}
|
||||
if (settings.containsKey("enableUrlToPDF")) {
|
||||
GeneralUtils.saveKeyToSettings("system.enableUrlToPDF", settings.get("enableUrlToPDF"));
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.setEnableUrlToPDF((Boolean) settings.get("enableUrlToPDF"));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
"Advanced settings updated. Restart required for changes to take effect.");
|
||||
}
|
||||
}
|
||||
|
||||
+4
-8
@@ -15,20 +15,18 @@ import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
@@ -36,9 +34,7 @@ import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ConvertEbookToPDFController {
|
||||
@@ -58,7 +54,7 @@ public class ConvertEbookToPDFController {
|
||||
return endpointConfiguration.isGroupEnabled("Ghostscript");
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf")
|
||||
@Operation(
|
||||
summary = "Convert an eBook file to PDF",
|
||||
description =
|
||||
|
||||
+4
-8
@@ -12,14 +12,10 @@ import org.apache.commons.io.FilenameUtils;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -28,15 +24,15 @@ import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.OutputFormat;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.TargetDevice;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ConvertPDFToEpubController {
|
||||
@@ -77,7 +73,7 @@ public class ConvertPDFToEpubController {
|
||||
return command;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/epub")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/epub")
|
||||
@Operation(
|
||||
summary = "Convert PDF to EPUB/AZW3",
|
||||
description =
|
||||
|
||||
+4
-8
@@ -3,31 +3,27 @@ package stirling.software.SPDF.controller.api.converters;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.util.PDFToFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@RestController
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPDFToHtml {
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html")
|
||||
@Operation(
|
||||
summary = "Convert PDF to HTML",
|
||||
description =
|
||||
|
||||
+7
-11
@@ -7,19 +7,17 @@ import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.PdfToPresentationRequest;
|
||||
import stirling.software.SPDF.model.api.converters.PdfToTextOrRTFRequest;
|
||||
import stirling.software.SPDF.model.api.converters.PdfToWordRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@@ -28,9 +26,7 @@ import stirling.software.common.util.PDFToFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPDFToOffice {
|
||||
|
||||
@@ -38,7 +34,7 @@ public class ConvertPDFToOffice {
|
||||
private final TempFileManager tempFileManager;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/presentation")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/presentation")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Presentation format",
|
||||
description =
|
||||
@@ -53,7 +49,7 @@ public class ConvertPDFToOffice {
|
||||
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "impress_pdf_import");
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/text")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/text")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Text or RTF format",
|
||||
description =
|
||||
@@ -79,7 +75,7 @@ public class ConvertPDFToOffice {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/word")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/word")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Word document",
|
||||
description =
|
||||
@@ -93,7 +89,7 @@ public class ConvertPDFToOffice {
|
||||
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/xml")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/xml")
|
||||
@Operation(
|
||||
summary = "Convert PDF to XML",
|
||||
description =
|
||||
|
||||
+17
-16
@@ -74,30 +74,26 @@ import org.apache.xmpbox.xml.XmpSerializer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@ConvertApi
|
||||
@Slf4j
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPDFToPDFA {
|
||||
|
||||
@@ -483,15 +479,22 @@ public class ConvertPDFToPDFA {
|
||||
command.add("-dCompatibilityLevel=" + profile.getCompatibilityLevel());
|
||||
command.add("-sDEVICE=pdfwrite");
|
||||
command.add("-sColorConversionStrategy=RGB");
|
||||
command.add("-dProcessColorModel=/DeviceRGB");
|
||||
command.add("-sOutputICCProfile=" + colorProfiles.rgb().toAbsolutePath());
|
||||
command.add("-sDefaultRGBProfile=" + colorProfiles.rgb().toAbsolutePath());
|
||||
command.add("-sDefaultGrayProfile=" + colorProfiles.gray().toAbsolutePath());
|
||||
command.add("-dEmbedAllFonts=true");
|
||||
command.add("-dSubsetFonts=false"); // Embed complete fonts to avoid incomplete glyphs
|
||||
command.add("-dSubsetFonts=true");
|
||||
command.add("-dCompressFonts=true");
|
||||
command.add("-dNOSUBSTFONTS=false"); // Allow font substitution for problematic fonts
|
||||
command.add("-dPDFSETTINGS=/prepress");
|
||||
|
||||
// Explicitly tune downsampling/compression for high-quality print
|
||||
command.add("-dColorImageDownsampleType=/Bicubic");
|
||||
command.add("-dColorImageResolution=300");
|
||||
command.add("-dGrayImageDownsampleType=/Bicubic");
|
||||
command.add("-dGrayImageResolution=300");
|
||||
command.add("-dMonoImageDownsampleType=/Bicubic");
|
||||
command.add("-dMonoImageResolution=1200");
|
||||
|
||||
command.add("-dNOPAUSE");
|
||||
command.add("-dBATCH");
|
||||
command.add("-dNOOUTERSAVE");
|
||||
@@ -562,7 +565,7 @@ public class ConvertPDFToPDFA {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/pdfa")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/pdfa")
|
||||
@Operation(
|
||||
summary = "Convert a PDF to a PDF/A or PDF/X",
|
||||
description =
|
||||
@@ -2449,9 +2452,7 @@ public class ConvertPDFToPDFA {
|
||||
|
||||
@Getter
|
||||
private enum PdfXProfile {
|
||||
PDF_X_1("PDF/X-1", "_PDFX-1.pdf", "1.3", "2001", "pdfx-1", "pdfx"),
|
||||
PDF_X_3("PDF/X-3", "_PDFX-3.pdf", "1.3", "2003", "pdfx-3"),
|
||||
PDF_X_4("PDF/X-4", "_PDFX-4.pdf", "1.4", "2008", "pdfx-4");
|
||||
PDF_X("PDF/X", "_PDFX.pdf", "1.6", "2008", "pdfx");
|
||||
|
||||
private final String displayName;
|
||||
private final String suffix;
|
||||
@@ -2477,7 +2478,7 @@ public class ConvertPDFToPDFA {
|
||||
|
||||
static PdfXProfile fromRequest(String requestToken) {
|
||||
if (requestToken == null) {
|
||||
return PDF_X_4;
|
||||
return PDF_X;
|
||||
}
|
||||
String normalized = requestToken.trim().toLowerCase(Locale.ROOT);
|
||||
Optional<PdfXProfile> match =
|
||||
@@ -2485,7 +2486,7 @@ public class ConvertPDFToPDFA {
|
||||
.filter(profile -> profile.requestTokens.contains(normalized))
|
||||
.findFirst();
|
||||
|
||||
return match.orElse(PDF_X_4);
|
||||
return match.orElse(PDF_X);
|
||||
}
|
||||
|
||||
String outputSuffix() {
|
||||
|
||||
+3
-4
@@ -9,7 +9,6 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -89,7 +88,7 @@ public class ConvertPdfJsonController {
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor/metadata")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor/metadata")
|
||||
@Operation(
|
||||
summary = "Extract PDF metadata for text editor lazy loading",
|
||||
description =
|
||||
@@ -127,7 +126,7 @@ public class ConvertPdfJsonController {
|
||||
.body(jsonBytes);
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
@AutoJobPostMapping(
|
||||
value = "/pdf/text-editor/partial/{jobId}",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@StandardPdfResponse
|
||||
@@ -180,7 +179,7 @@ public class ConvertPdfJsonController {
|
||||
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
|
||||
@AutoJobPostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
|
||||
@Operation(
|
||||
summary = "Clear cached PDF document for text editor",
|
||||
description =
|
||||
|
||||
+4
-8
@@ -27,18 +27,16 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.PdfToVideoRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ApplicationContextProvider;
|
||||
@@ -51,9 +49,7 @@ import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPdfToVideoController {
|
||||
|
||||
@@ -68,7 +64,7 @@ public class ConvertPdfToVideoController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/video")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/video")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Video Slideshow",
|
||||
description =
|
||||
|
||||
+4
-8
@@ -21,19 +21,17 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.UrlToPdfRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@@ -43,10 +41,8 @@ import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@ConvertApi
|
||||
@Slf4j
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertWebsiteToPDF {
|
||||
|
||||
@@ -59,7 +55,7 @@ public class ConvertWebsiteToPDF {
|
||||
|
||||
private static final Pattern NUMERIC_HTML_ENTITY_PATTERN = Pattern.compile("&#(x?[0-9a-f]+);");
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/url/pdf")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/url/pdf")
|
||||
@Operation(
|
||||
summary = "Convert a URL to a PDF",
|
||||
description =
|
||||
|
||||
+5
-9
@@ -13,12 +13,8 @@ import org.apache.commons.io.FilenameUtils;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
@@ -27,6 +23,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.PdfVectorExportRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
@@ -35,10 +33,8 @@ import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@ConvertApi
|
||||
@Slf4j
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class PdfVectorExportController {
|
||||
|
||||
@@ -49,7 +45,7 @@ public class PdfVectorExportController {
|
||||
private final TempFileManager tempFileManager;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/vector/pdf")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/vector/pdf")
|
||||
@Operation(
|
||||
summary = "Convert PostScript formats to PDF",
|
||||
description =
|
||||
@@ -94,7 +90,7 @@ public class PdfVectorExportController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/vector")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/vector")
|
||||
@Operation(
|
||||
summary = "Convert PDF to vector format",
|
||||
description =
|
||||
|
||||
+17
-13
@@ -8,9 +8,6 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
@@ -18,7 +15,6 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@@ -28,20 +24,22 @@ import stirling.software.SPDF.model.api.filter.ContainsTextRequest;
|
||||
import stirling.software.SPDF.model.api.filter.FileSizeRequest;
|
||||
import stirling.software.SPDF.model.api.filter.PageRotationRequest;
|
||||
import stirling.software.SPDF.model.api.filter.PageSizeRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.FilterApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.PdfUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/filter")
|
||||
@Tag(name = "Filter", description = "Filter APIs")
|
||||
@FilterApi
|
||||
@RequiredArgsConstructor
|
||||
public class FilterController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-contains-text")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/filter-contains-text")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF contains set text, returns true if does",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -70,7 +68,9 @@ public class FilterController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-contains-image")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/filter-contains-image")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF contains an image",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -98,7 +98,9 @@ public class FilterController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-count")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/filter-page-count")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF is greater, less or equal to a setPageCount",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -129,7 +131,7 @@ public class FilterController {
|
||||
: ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-size")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-size")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF is of a certain size",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -166,7 +168,7 @@ public class FilterController {
|
||||
: ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-file-size")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-file-size")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF is a set file size",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -194,7 +196,9 @@ public class FilterController {
|
||||
: ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-rotation")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/filter-page-rotation")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF is of a certain rotation",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
|
||||
+4
-8
@@ -36,20 +36,18 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.*;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.LineArtConversionService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
@@ -60,10 +58,8 @@ import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@MiscApi
|
||||
@Slf4j
|
||||
@Tag(name = "Misc", description = "Miscellaneous APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class CompressController {
|
||||
|
||||
@@ -922,7 +918,7 @@ public class CompressController {
|
||||
return Math.min(9, currentLevel + 1);
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/compress-pdf")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/compress-pdf")
|
||||
@Operation(
|
||||
summary = "Optimize PDF file",
|
||||
description =
|
||||
|
||||
+10
@@ -112,6 +112,16 @@ public class ConfigController {
|
||||
"showSettingsWhenNoLogin",
|
||||
applicationProperties.getSystem().isShowSettingsWhenNoLogin());
|
||||
|
||||
// SSO Provider settings
|
||||
boolean enableOAuth =
|
||||
applicationProperties.getSecurity().getOauth2() != null
|
||||
&& applicationProperties.getSecurity().getOauth2().getEnabled();
|
||||
boolean enableSaml =
|
||||
applicationProperties.getSecurity().getSaml2() != null
|
||||
&& applicationProperties.getSecurity().getSaml2().getEnabled();
|
||||
configData.put("enableOAuth", enableOAuth);
|
||||
configData.put("enableSaml", enableSaml);
|
||||
|
||||
// Mail settings - check both SMTP enabled AND invites enabled
|
||||
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
|
||||
boolean invitesEnabled = applicationProperties.getMail().isEnableInvites();
|
||||
|
||||
+4
-8
@@ -24,20 +24,18 @@ import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@@ -50,9 +48,7 @@ import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@Tag(name = "Misc", description = "Miscellaneous APIs")
|
||||
@MiscApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class OCRController {
|
||||
@@ -85,7 +81,7 @@ public class OCRController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ocr-pdf")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ocr-pdf")
|
||||
@Operation(
|
||||
summary = "Process a PDF file with OCR",
|
||||
description =
|
||||
|
||||
+10
-1
@@ -52,9 +52,17 @@ public class PageNumbersController {
|
||||
int pageNumber = request.getStartingNumber();
|
||||
String pagesToNumber = request.getPagesToNumber();
|
||||
String customText = request.getCustomText();
|
||||
int zeroPad = request.getZeroPad();
|
||||
float fontSize = request.getFontSize();
|
||||
String fontType = request.getFontType();
|
||||
String fontColor = request.getFontColor();
|
||||
// compute padded number string where requested
|
||||
String formatN;
|
||||
if (zeroPad > 0) {
|
||||
formatN = String.format("%%0%dd", Math.max(0, zeroPad));
|
||||
} else {
|
||||
formatN = "%d";
|
||||
}
|
||||
|
||||
Color color = Color.BLACK;
|
||||
if (fontColor != null && !fontColor.trim().isEmpty()) {
|
||||
@@ -93,9 +101,10 @@ public class PageNumbersController {
|
||||
PDPage page = document.getPage(i);
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
|
||||
String nFormatted = String.format(formatN, pageNumber);
|
||||
String text =
|
||||
customText
|
||||
.replace("{n}", String.valueOf(pageNumber))
|
||||
.replace("{n}", nFormatted)
|
||||
.replace("{total}", String.valueOf(document.getNumberOfPages()))
|
||||
.replace(
|
||||
"{filename}",
|
||||
|
||||
+202
-65
@@ -7,11 +7,13 @@ import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
@@ -34,17 +36,15 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.WebDataBinder;
|
||||
import org.springframework.web.bind.annotation.InitBinder;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.AddStampRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
@@ -53,15 +53,20 @@ import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@Tag(name = "Misc", description = "Miscellaneous APIs")
|
||||
@MiscApi
|
||||
@RequiredArgsConstructor
|
||||
public class StampController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
private static final int MAX_DATE_FORMAT_LENGTH = 50;
|
||||
private static final Pattern SAFE_DATE_FORMAT_PATTERN =
|
||||
Pattern.compile("^[yMdHhmsS/\\-:\\s.,'+EGuwWDFzZXa]+$");
|
||||
private static final Pattern CUSTOM_DATE_PATTERN = Pattern.compile("@date\\{([^}]{1,50})\\}");
|
||||
// Placeholder for escaped @ symbol (using Unicode private use area)
|
||||
private static final String ESCAPED_AT_PLACEHOLDER = "\uE000ESCAPED_AT\uE000";
|
||||
|
||||
/**
|
||||
* Initialize data binder for multipart file uploads. This method registers a custom editor for
|
||||
* MultipartFile to handle file uploads. It sets the MultipartFile to null if the uploaded file
|
||||
@@ -79,7 +84,7 @@ public class StampController {
|
||||
});
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-stamp")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-stamp")
|
||||
@Operation(
|
||||
summary = "Add stamp to a PDF file",
|
||||
description =
|
||||
@@ -170,7 +175,9 @@ public class StampController {
|
||||
overrideX,
|
||||
overrideY,
|
||||
margin,
|
||||
customColor);
|
||||
customColor,
|
||||
pageIndex,
|
||||
pdfFileName);
|
||||
} else if ("image".equalsIgnoreCase(stampType)) {
|
||||
addImageStamp(
|
||||
contentStream,
|
||||
@@ -205,9 +212,11 @@ public class StampController {
|
||||
float fontSize,
|
||||
String alphabet,
|
||||
float overrideX, // X override
|
||||
float overrideY,
|
||||
float overrideY, // Y override
|
||||
float margin,
|
||||
String colorString) // Y override
|
||||
String colorString,
|
||||
int currentPageNumber,
|
||||
String filename)
|
||||
throws IOException {
|
||||
String resourceDir;
|
||||
PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
@@ -235,8 +244,6 @@ public class StampController {
|
||||
}
|
||||
}
|
||||
|
||||
contentStream.setFont(font, fontSize);
|
||||
|
||||
Color redactColor;
|
||||
try {
|
||||
if (!colorString.startsWith("#")) {
|
||||
@@ -244,45 +251,54 @@ public class StampController {
|
||||
}
|
||||
redactColor = Color.decode(colorString);
|
||||
} catch (NumberFormatException e) {
|
||||
|
||||
redactColor = Color.LIGHT_GRAY;
|
||||
}
|
||||
|
||||
contentStream.setNonStrokingColor(redactColor);
|
||||
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
float x, y;
|
||||
|
||||
if (overrideX >= 0 && overrideY >= 0) {
|
||||
// Use override values if provided
|
||||
x = overrideX;
|
||||
y = overrideY;
|
||||
} else {
|
||||
x = calculatePositionX(pageSize, position, fontSize, font, fontSize, stampText, margin);
|
||||
y =
|
||||
calculatePositionY(
|
||||
pageSize, position, calculateTextCapHeight(font, fontSize), margin);
|
||||
}
|
||||
|
||||
String currentDate = LocalDate.now().toString();
|
||||
String currentTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
|
||||
|
||||
int pageCount = document.getNumberOfPages();
|
||||
|
||||
String processedStampText =
|
||||
stampText
|
||||
.replace("@date", currentDate)
|
||||
.replace("@time", currentTime)
|
||||
.replace("@page_count", String.valueOf(pageCount));
|
||||
processStampText(stampText, currentPageNumber, pageCount, filename, document);
|
||||
|
||||
// Split the stampText into multiple lines
|
||||
String[] lines =
|
||||
RegexPatternUtils.getInstance().getEscapedNewlinePattern().split(stampText);
|
||||
String normalizedText =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getEscapedNewlinePattern()
|
||||
.matcher(processedStampText)
|
||||
.replaceAll("\n");
|
||||
String[] lines = normalizedText.split("\\r?\\n");
|
||||
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
|
||||
// Use fontSize directly (default 40 if not specified)
|
||||
float effectiveFontSize = fontSize > 0 ? fontSize : 40f;
|
||||
|
||||
contentStream.setFont(font, effectiveFontSize);
|
||||
|
||||
// Calculate dynamic line height based on font ascent and descent
|
||||
float ascent = font.getFontDescriptor().getAscent();
|
||||
float descent = font.getFontDescriptor().getDescent();
|
||||
float lineHeight = ((ascent - descent) / 1000) * fontSize;
|
||||
float lineHeight = ((ascent - descent) / 1000) * effectiveFontSize;
|
||||
|
||||
float maxLineWidth = 0;
|
||||
for (String line : lines) {
|
||||
float lineWidth = calculateTextWidth(line, font, effectiveFontSize);
|
||||
if (lineWidth > maxLineWidth) {
|
||||
maxLineWidth = lineWidth;
|
||||
}
|
||||
}
|
||||
|
||||
float totalTextHeight = lines.length * lineHeight;
|
||||
|
||||
float x, y;
|
||||
|
||||
if (overrideX >= 0 && overrideY >= 0) {
|
||||
x = overrideX;
|
||||
y = overrideY;
|
||||
} else {
|
||||
x = calculatePositionX(pageSize, position, maxLineWidth, margin);
|
||||
y = calculatePositionY(pageSize, position, totalTextHeight, margin);
|
||||
}
|
||||
|
||||
contentStream.beginText();
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
@@ -295,6 +311,140 @@ public class StampController {
|
||||
contentStream.endText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process stamp text by replacing all @commands with their actual values. Supported commands:
|
||||
*
|
||||
* <p>Date & Time:
|
||||
*
|
||||
* <ul>
|
||||
* <li>@date - Current date (YYYY-MM-DD)
|
||||
* <li>@time - Current time (HH:mm:ss)
|
||||
* <li>@datetime - Current date and time (YYYY-MM-DD HH:mm:ss)
|
||||
* <li>@date{format} - Custom date/time format (e.g., @date{dd/MM/yyyy})
|
||||
* <li>@year - Current year (4 digits)
|
||||
* <li>@month - Current month (01-12)
|
||||
* <li>@day - Current day of month (01-31)
|
||||
* </ul>
|
||||
*
|
||||
* <p>Page Information:
|
||||
*
|
||||
* <ul>
|
||||
* <li>@page_number or @page - Current page number
|
||||
* <li>@total_pages or @page_count - Total number of pages
|
||||
* </ul>
|
||||
*
|
||||
* <p>File Information:
|
||||
*
|
||||
* <ul>
|
||||
* <li>@filename - Original filename (without extension)
|
||||
* <li>@filename_full - Original filename (with extension)
|
||||
* </ul>
|
||||
*
|
||||
* <p>Document Metadata:
|
||||
*
|
||||
* <ul>
|
||||
* <li>@author - Document author (from PDF metadata)
|
||||
* <li>@title - Document title (from PDF metadata)
|
||||
* <li>@subject - Document subject (from PDF metadata)
|
||||
* </ul>
|
||||
*
|
||||
* <p>Other:
|
||||
*
|
||||
* <ul>
|
||||
* <li>@uuid - Short unique identifier (8 characters)
|
||||
* </ul>
|
||||
*/
|
||||
private String processStampText(
|
||||
String stampText,
|
||||
int currentPageNumber,
|
||||
int totalPages,
|
||||
String filename,
|
||||
PDDocument document) {
|
||||
if (stampText == null || stampText.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Handle escaped @@ sequences first - replace with placeholder to preserve literal @
|
||||
String result = stampText.replace("@@", ESCAPED_AT_PLACEHOLDER);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String currentDate = now.toLocalDate().toString();
|
||||
String currentTime = now.toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
|
||||
String currentDateTime = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||
|
||||
String filenameWithoutExt = filename != null ? filename : "";
|
||||
if (filename != null && filename.contains(".")) {
|
||||
int lastDot = filename.lastIndexOf('.');
|
||||
if (lastDot > 0) { // Ensure there's actually a name before the dot
|
||||
filenameWithoutExt = filename.substring(0, lastDot);
|
||||
}
|
||||
}
|
||||
|
||||
String author = "";
|
||||
String title = "";
|
||||
String subject = "";
|
||||
if (document != null && document.getDocumentInformation() != null) {
|
||||
var info = document.getDocumentInformation();
|
||||
author = info.getAuthor() != null ? info.getAuthor() : "";
|
||||
title = info.getTitle() != null ? info.getTitle() : "";
|
||||
subject = info.getSubject() != null ? info.getSubject() : "";
|
||||
}
|
||||
|
||||
String uuid = UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
// Process @date{format} with custom format first (must be before simple @date)
|
||||
Matcher matcher = CUSTOM_DATE_PATTERN.matcher(result);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (matcher.find()) {
|
||||
String format = matcher.group(1);
|
||||
String replacement = processCustomDateFormat(format, now);
|
||||
matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
matcher.appendTail(sb);
|
||||
result = sb.toString();
|
||||
|
||||
result =
|
||||
result.replace("@datetime", currentDateTime)
|
||||
.replace("@date", currentDate)
|
||||
.replace("@time", currentTime)
|
||||
.replace("@year", String.valueOf(now.getYear()))
|
||||
.replace("@month", String.format("%02d", now.getMonthValue()))
|
||||
.replace("@day", String.format("%02d", now.getDayOfMonth()))
|
||||
.replace("@page_number", String.valueOf(currentPageNumber))
|
||||
.replace(
|
||||
"@page_count", String.valueOf(totalPages)) // Must come before @page
|
||||
.replace("@total_pages", String.valueOf(totalPages))
|
||||
.replace(
|
||||
"@page",
|
||||
String.valueOf(currentPageNumber)) // Must come after @page_count
|
||||
.replace("@filename_full", filename != null ? filename : "")
|
||||
.replace("@filename", filenameWithoutExt)
|
||||
.replace("@author", author)
|
||||
.replace("@title", title)
|
||||
.replace("@subject", subject)
|
||||
.replace("@uuid", uuid);
|
||||
|
||||
result = result.replace(ESCAPED_AT_PLACEHOLDER, "@");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private String processCustomDateFormat(String format, LocalDateTime now) {
|
||||
if (format == null || format.length() > MAX_DATE_FORMAT_LENGTH) {
|
||||
return "[invalid format: too long]";
|
||||
}
|
||||
|
||||
if (!SAFE_DATE_FORMAT_PATTERN.matcher(format).matches()) {
|
||||
return "[invalid format]";
|
||||
}
|
||||
|
||||
try {
|
||||
return now.format(DateTimeFormatter.ofPattern(format));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return "[invalid format: " + format + "]";
|
||||
}
|
||||
}
|
||||
|
||||
private void addImageStamp(
|
||||
PDPageContentStream contentStream,
|
||||
MultipartFile stampImage,
|
||||
@@ -331,8 +481,8 @@ public class StampController {
|
||||
x = overrideX;
|
||||
y = overrideY;
|
||||
} else {
|
||||
x = calculatePositionX(pageSize, position, desiredPhysicalWidth, null, 0, null, margin);
|
||||
y = calculatePositionY(pageSize, position, fontSize, margin);
|
||||
x = calculatePositionX(pageSize, position, desiredPhysicalWidth, margin);
|
||||
y = calculatePositionY(pageSize, position, desiredPhysicalHeight, margin);
|
||||
}
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
@@ -343,23 +493,14 @@ public class StampController {
|
||||
}
|
||||
|
||||
private float calculatePositionX(
|
||||
PDRectangle pageSize,
|
||||
int position,
|
||||
float contentWidth,
|
||||
PDFont font,
|
||||
float fontSize,
|
||||
String text,
|
||||
float margin)
|
||||
throws IOException {
|
||||
float actualWidth =
|
||||
(text != null) ? calculateTextWidth(text, font, fontSize) : contentWidth;
|
||||
PDRectangle pageSize, int position, float contentWidth, float margin) {
|
||||
return switch (position % 3) {
|
||||
case 1: // Left
|
||||
yield pageSize.getLowerLeftX() + margin;
|
||||
case 2: // Center
|
||||
yield (pageSize.getWidth() - actualWidth) / 2;
|
||||
yield (pageSize.getWidth() - contentWidth) / 2;
|
||||
case 0: // Right
|
||||
yield pageSize.getUpperRightX() - actualWidth - margin;
|
||||
yield pageSize.getUpperRightX() - contentWidth - margin;
|
||||
default:
|
||||
yield 0;
|
||||
};
|
||||
@@ -368,12 +509,12 @@ public class StampController {
|
||||
private float calculatePositionY(
|
||||
PDRectangle pageSize, int position, float height, float margin) {
|
||||
return switch ((position - 1) / 3) {
|
||||
case 0: // Top
|
||||
yield pageSize.getUpperRightY() - height - margin;
|
||||
case 1: // Middle
|
||||
yield (pageSize.getHeight() - height) / 2;
|
||||
case 2: // Bottom
|
||||
yield pageSize.getLowerLeftY() + margin;
|
||||
case 0: // Top - first line near the top
|
||||
yield pageSize.getUpperRightY() - margin;
|
||||
case 1: // Middle - center of text block at page center
|
||||
yield (pageSize.getHeight() + height) / 2;
|
||||
case 2: // Bottom - first line positioned so last line is at bottom margin
|
||||
yield pageSize.getLowerLeftY() + margin + height;
|
||||
default:
|
||||
yield 0;
|
||||
};
|
||||
@@ -382,8 +523,4 @@ public class StampController {
|
||||
private float calculateTextWidth(String text, PDFont font, float fontSize) throws IOException {
|
||||
return font.getStringWidth(text) / 1000 * fontSize;
|
||||
}
|
||||
|
||||
private float calculateTextCapHeight(PDFont font, float fontSize) {
|
||||
return font.getFontDescriptor().getCapHeight() / 1000 * fontSize;
|
||||
}
|
||||
}
|
||||
|
||||
+59
-6
@@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api.pipeline;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileSystemException;
|
||||
import java.nio.file.FileVisitOption;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -14,6 +15,7 @@ import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -41,14 +43,20 @@ import stirling.software.common.util.FileMonitor;
|
||||
@Slf4j
|
||||
public class PipelineDirectoryProcessor {
|
||||
|
||||
private static final int MAX_DIRECTORY_DEPTH = 50; // Prevent excessive recursion
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApiDocService apiDocService;
|
||||
private final PipelineProcessor processor;
|
||||
private final FileMonitor fileMonitor;
|
||||
private final PostHogService postHogService;
|
||||
private final String watchedFoldersDir;
|
||||
private final List<String> watchedFoldersDirs;
|
||||
private final String finishedFoldersDir;
|
||||
|
||||
// Track processed directories in current scan to prevent duplicates
|
||||
private final ThreadLocal<java.util.Set<Path>> processedDirsInScan =
|
||||
ThreadLocal.withInitial(java.util.HashSet::new);
|
||||
|
||||
public PipelineDirectoryProcessor(
|
||||
ObjectMapper objectMapper,
|
||||
ApiDocService apiDocService,
|
||||
@@ -61,13 +69,26 @@ public class PipelineDirectoryProcessor {
|
||||
this.processor = processor;
|
||||
this.fileMonitor = fileMonitor;
|
||||
this.postHogService = postHogService;
|
||||
this.watchedFoldersDir = runtimePathConfig.getPipelineWatchedFoldersPath();
|
||||
this.watchedFoldersDirs = runtimePathConfig.getPipelineWatchedFoldersPaths();
|
||||
this.finishedFoldersDir = runtimePathConfig.getPipelineFinishedFoldersPath();
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 60000)
|
||||
public void scanFolders() {
|
||||
Path watchedFolderPath = Paths.get(watchedFoldersDir).toAbsolutePath();
|
||||
// Clear the processed directories set for this scan cycle
|
||||
processedDirsInScan.get().clear();
|
||||
|
||||
try {
|
||||
for (String watchedFoldersDir : watchedFoldersDirs) {
|
||||
scanWatchedFolder(Paths.get(watchedFoldersDir).toAbsolutePath());
|
||||
}
|
||||
} finally {
|
||||
// Clean up ThreadLocal to prevent memory leaks
|
||||
processedDirsInScan.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private void scanWatchedFolder(Path watchedFolderPath) {
|
||||
if (!Files.exists(watchedFolderPath)) {
|
||||
try {
|
||||
Files.createDirectories(watchedFolderPath);
|
||||
@@ -78,16 +99,34 @@ public class PipelineDirectoryProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the path is a directory and readable
|
||||
if (!Files.isDirectory(watchedFolderPath)) {
|
||||
log.error("Path is not a directory: {}", watchedFolderPath);
|
||||
return;
|
||||
}
|
||||
if (!Files.isReadable(watchedFolderPath)) {
|
||||
log.error("Directory is not readable: {}", watchedFolderPath);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use FOLLOW_LINKS to follow symlinks, with max depth to prevent infinite loops
|
||||
Files.walkFileTree(
|
||||
watchedFolderPath,
|
||||
EnumSet.of(FileVisitOption.FOLLOW_LINKS),
|
||||
MAX_DIRECTORY_DEPTH,
|
||||
new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(
|
||||
Path dir, BasicFileAttributes attrs) {
|
||||
try {
|
||||
String dirName =
|
||||
dir.getFileName() != null
|
||||
? dir.getFileName().toString()
|
||||
: "";
|
||||
// Skip root directory and "processing" subdirectories
|
||||
if (!dir.equals(watchedFolderPath) && !dir.endsWith("processing")) {
|
||||
if (!dir.equals(watchedFolderPath)
|
||||
&& !"processing".equals(dirName)) {
|
||||
handleDirectory(dir);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@@ -98,8 +137,11 @@ public class PipelineDirectoryProcessor {
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFileFailed(Path path, IOException exc) {
|
||||
// Handle broken symlinks or inaccessible directories
|
||||
log.error("Error accessing path: {}", path, exc);
|
||||
// Handle broken symlinks, permission issues, or inaccessible
|
||||
// directories
|
||||
if (exc != null) {
|
||||
log.debug("Cannot access path '{}': {}", path, exc.getMessage());
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
@@ -109,6 +151,17 @@ public class PipelineDirectoryProcessor {
|
||||
}
|
||||
|
||||
public void handleDirectory(Path dir) throws IOException {
|
||||
// Normalize path to absolute to prevent duplicate processing from different path
|
||||
// representations
|
||||
Path normalizedDir = dir.toAbsolutePath().normalize();
|
||||
|
||||
// Check if we've already processed this directory in this scan cycle
|
||||
java.util.Set<Path> processedDirs = processedDirsInScan.get();
|
||||
if (!processedDirs.add(normalizedDir)) {
|
||||
log.debug("Directory already processed in this scan cycle: {}", normalizedDir);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Handling directory: {}", dir);
|
||||
Path processingDir = createProcessingDirectory(dir);
|
||||
Optional<Path> jsonFileOptional = findJsonFile(dir);
|
||||
|
||||
+124
-279
@@ -3,21 +3,15 @@ package stirling.software.SPDF.controller.api.security;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.cos.COSInputStream;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.io.RandomAccessRead;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
|
||||
import org.apache.pdfbox.pdmodel.*;
|
||||
import org.apache.pdfbox.pdmodel.common.PDMetadata;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
@@ -35,8 +29,7 @@ import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentGroup;
|
||||
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentProperties;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.*;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationFileAttachment;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
|
||||
@@ -44,23 +37,14 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlin
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.preflight.PreflightDocument;
|
||||
import org.apache.pdfbox.preflight.ValidationResult;
|
||||
import org.apache.pdfbox.preflight.exception.SyntaxValidationException;
|
||||
import org.apache.pdfbox.preflight.exception.ValidationException;
|
||||
import org.apache.pdfbox.preflight.parser.PreflightParser;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.xmpbox.XMPMetadata;
|
||||
import org.apache.xmpbox.schema.PDFAIdentificationSchema;
|
||||
import org.apache.xmpbox.xml.DomXmpParser;
|
||||
import org.apache.xmpbox.xml.XmpParsingException;
|
||||
import org.apache.xmpbox.xml.XmpSerializer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -68,21 +52,22 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
|
||||
import stirling.software.SPDF.service.VeraPDFService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.SecurityApi;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/security")
|
||||
@SecurityApi
|
||||
@Slf4j
|
||||
@Tag(name = "Security", description = "Security APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class GetInfoOnPDF {
|
||||
|
||||
@@ -95,6 +80,7 @@ public class GetInfoOnPDF {
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final VeraPDFService veraPDFService;
|
||||
|
||||
private static void addOutlinesToArray(PDOutlineItem outline, ArrayNode arrayNode) {
|
||||
if (outline == null) return;
|
||||
@@ -111,214 +97,8 @@ public class GetInfoOnPDF {
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean checkForStandard(PDDocument document, String standardKeyword) {
|
||||
if ("PDF/A".equalsIgnoreCase(standardKeyword)) {
|
||||
return getPdfAConformanceLevel(document) != null;
|
||||
}
|
||||
|
||||
return checkStandardInMetadata(document, standardKeyword);
|
||||
}
|
||||
|
||||
public static String getPdfAConformanceLevel(PDDocument document) {
|
||||
if (document == null || document.isEncrypted()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getPdfAVersionFromMetadata(document);
|
||||
}
|
||||
|
||||
private static String getPdfAVersionFromMetadata(PDDocument document) {
|
||||
try {
|
||||
PDMetadata pdMetadata = document.getDocumentCatalog().getMetadata();
|
||||
if (pdMetadata != null) {
|
||||
try (COSInputStream metaStream = pdMetadata.createInputStream()) {
|
||||
DomXmpParser domXmpParser = new DomXmpParser();
|
||||
XMPMetadata xmpMeta = domXmpParser.parse(metaStream);
|
||||
|
||||
PDFAIdentificationSchema pdfId = xmpMeta.getPDFAIdentificationSchema();
|
||||
if (pdfId != null) {
|
||||
Integer part = pdfId.getPart();
|
||||
String conformance = pdfId.getConformance();
|
||||
|
||||
if (part != null && conformance != null) {
|
||||
return part + conformance.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
} else {
|
||||
try (COSInputStream rawStream = pdMetadata.createInputStream()) {
|
||||
byte[] metadataBytes = rawStream.readAllBytes();
|
||||
String rawMetadata = new String(metadataBytes, StandardCharsets.UTF_8);
|
||||
String extracted = extractPdfAVersionFromRawXml(rawMetadata);
|
||||
if (extracted != null) {
|
||||
return extracted;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (XmpParsingException e) {
|
||||
log.debug("XMP parsing failed, trying raw metadata search: {}", e.getMessage());
|
||||
try (COSInputStream metaStream = pdMetadata.createInputStream()) {
|
||||
byte[] metadataBytes = metaStream.readAllBytes();
|
||||
String rawMetadata = new String(metadataBytes, StandardCharsets.UTF_8);
|
||||
String extracted = extractPdfAVersionFromRawXml(rawMetadata);
|
||||
if (extracted != null) {
|
||||
return extracted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Error reading PDF/A metadata: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String extractPdfAVersionFromRawXml(String rawXml) {
|
||||
if (rawXml == null || rawXml.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Pattern partPattern = RegexPatternUtils.getInstance().getPdfAidPartPattern();
|
||||
Pattern confPattern = RegexPatternUtils.getInstance().getPdfAidConformancePattern();
|
||||
|
||||
Matcher partMatcher = partPattern.matcher(rawXml);
|
||||
Matcher confMatcher = confPattern.matcher(rawXml);
|
||||
|
||||
if (partMatcher.find() && confMatcher.find()) {
|
||||
String part = partMatcher.group(1);
|
||||
String conformance = confMatcher.group(1).toUpperCase(Locale.ROOT);
|
||||
return part + conformance;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Error parsing raw XMP for PDF/A version: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean validatePdfAWithPreflight(PDDocument document, String version) {
|
||||
if (document == null || document.isEncrypted()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use Stream-to-File pattern: save to temp file instead of loading into memory
|
||||
// This prevents OutOfMemoryError on large PDFs
|
||||
Path tempFile = null;
|
||||
try {
|
||||
tempFile = Files.createTempFile("preflight-", ".pdf");
|
||||
|
||||
// Save document to temp file (avoids loading entire document into memory)
|
||||
try (var outputStream = Files.newOutputStream(tempFile)) {
|
||||
document.save(outputStream);
|
||||
}
|
||||
|
||||
// Use RandomAccessReadBufferedFile for efficient file-based reading
|
||||
// This avoids Windows file locking issues that occur with memory-mapped files
|
||||
try (RandomAccessRead source = new RandomAccessReadBufferedFile(tempFile.toFile())) {
|
||||
PreflightParser parser = new PreflightParser(source);
|
||||
|
||||
try (PDDocument parsedDocument = parser.parse()) {
|
||||
if (!(parsedDocument instanceof PreflightDocument preflightDocument)) {
|
||||
log.debug(
|
||||
"Parsed document is not a PreflightDocument; unable to validate claimed PDF/A {}",
|
||||
version);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
ValidationResult result = preflightDocument.validate();
|
||||
if (!result.isValid() && log.isDebugEnabled()) {
|
||||
log.debug(
|
||||
"PDF/A validation found {} errors for claimed version {}",
|
||||
result.getErrorsList().size(),
|
||||
version);
|
||||
int logged = 0;
|
||||
for (ValidationResult.ValidationError error : result.getErrorsList()) {
|
||||
log.debug(
|
||||
" Error {}: {}", error.getErrorCode(), error.getDetails());
|
||||
if (++logged >= MAX_LOGGED_ERRORS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.isValid();
|
||||
} catch (ValidationException e) {
|
||||
log.debug(
|
||||
"Validation exception during PDF/A validation: {}", e.getMessage());
|
||||
}
|
||||
} catch (SyntaxValidationException e) {
|
||||
log.debug(
|
||||
"Syntax validation failed during PDF/A validation: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("IOException during PDF/A validation: {}", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.debug("Unexpected error during PDF/A validation: {}", e.getMessage());
|
||||
} finally {
|
||||
// Explicitly clean up temp file to prevent disk exhaustion
|
||||
// This must be in finally block to ensure cleanup even on exceptions
|
||||
if (tempFile != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to delete temp file during PDF/A validation cleanup: {}",
|
||||
tempFile,
|
||||
e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean checkStandardInMetadata(PDDocument document, String standardKeyword) {
|
||||
// Check XMP Metadata
|
||||
try {
|
||||
PDMetadata pdMetadata = document.getDocumentCatalog().getMetadata();
|
||||
if (pdMetadata != null) {
|
||||
try (COSInputStream metaStream = pdMetadata.createInputStream()) {
|
||||
// First try to read raw metadata as string to check for standard keywords
|
||||
byte[] metadataBytes = metaStream.readAllBytes();
|
||||
String rawMetadata = new String(metadataBytes, StandardCharsets.UTF_8);
|
||||
|
||||
if (rawMetadata.contains(standardKeyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If raw check doesn't find it, try parsing with XMP parser
|
||||
try (COSInputStream metaStream = pdMetadata.createInputStream()) {
|
||||
try {
|
||||
DomXmpParser domXmpParser = new DomXmpParser();
|
||||
XMPMetadata xmpMeta = domXmpParser.parse(metaStream);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
new XmpSerializer().serialize(xmpMeta, baos, true);
|
||||
String xmpString = baos.toString(StandardCharsets.UTF_8);
|
||||
|
||||
if (xmpString.contains(standardKeyword)) {
|
||||
return true;
|
||||
}
|
||||
} catch (XmpParsingException e) {
|
||||
// XMP parsing failed, but we already checked raw metadata above
|
||||
log.debug(
|
||||
"XMP parsing failed for standard check, but raw metadata was already checked: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("PDF standard checking", e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ObjectNode generatePDFSummaryData(
|
||||
PDDocument document, String pdfaConformanceLevel, Boolean pdfaValidationPassed) {
|
||||
PDDocument document, List<PDFVerificationResult> verificationResults) {
|
||||
ObjectNode summaryData = objectMapper.createObjectNode();
|
||||
|
||||
// Check if encrypted
|
||||
@@ -346,24 +126,16 @@ public class GetInfoOnPDF {
|
||||
}
|
||||
|
||||
// Check standard compliance
|
||||
if (pdfaConformanceLevel != null) {
|
||||
summaryData.put("standardCompliance", "PDF/A-" + pdfaConformanceLevel);
|
||||
summaryData.put("standardPurpose", "long-term archiving");
|
||||
if (pdfaValidationPassed != null) {
|
||||
summaryData.put("standardValidationPassed", pdfaValidationPassed);
|
||||
if (verificationResults != null && !verificationResults.isEmpty()) {
|
||||
ArrayNode complianceArray = objectMapper.createArrayNode();
|
||||
for (PDFVerificationResult result : verificationResults) {
|
||||
ObjectNode complianceNode = objectMapper.createObjectNode();
|
||||
complianceNode.put("Standard", result.getStandard());
|
||||
complianceNode.put("Compliant", result.isCompliant());
|
||||
complianceNode.put("Summary", result.getComplianceSummary());
|
||||
complianceArray.add(complianceNode);
|
||||
}
|
||||
} else if (checkForStandard(document, "PDF/X")) {
|
||||
summaryData.put("standardCompliance", "PDF/X");
|
||||
summaryData.put("standardPurpose", "graphic exchange");
|
||||
} else if (checkForStandard(document, "PDF/UA")) {
|
||||
summaryData.put("standardCompliance", "PDF/UA");
|
||||
summaryData.put("standardPurpose", "universal accessibility");
|
||||
} else if (checkForStandard(document, "PDF/E")) {
|
||||
summaryData.put("standardCompliance", "PDF/E");
|
||||
summaryData.put("standardPurpose", "engineering workflows");
|
||||
} else if (checkForStandard(document, "PDF/VT")) {
|
||||
summaryData.put("standardCompliance", "PDF/VT");
|
||||
summaryData.put("standardPurpose", "variable and transactional printing");
|
||||
summaryData.set("Compliance", complianceArray);
|
||||
}
|
||||
|
||||
return summaryData;
|
||||
@@ -591,40 +363,113 @@ public class GetInfoOnPDF {
|
||||
return docInfoNode;
|
||||
}
|
||||
|
||||
private static ObjectNode extractComplianceInfo(PDDocument document) {
|
||||
private static ObjectNode extractComplianceInfo(
|
||||
PDDocument doc, List<PDFVerificationResult> verificationResults) {
|
||||
ObjectNode compliancy = objectMapper.createObjectNode();
|
||||
|
||||
try {
|
||||
String pdfaConformanceLevel = getPdfAConformanceLevel(document);
|
||||
boolean isPdfACompliant = pdfaConformanceLevel != null;
|
||||
boolean isPdfXCompliant = checkForStandard(document, "PDF/X");
|
||||
boolean isPdfECompliant = checkForStandard(document, "PDF/E");
|
||||
boolean isPdfVTCompliant = checkForStandard(document, "PDF/VT");
|
||||
boolean isPdfUACompliant = checkForStandard(document, "PDF/UA");
|
||||
boolean isPdfBCompliant = checkForStandard(document, "PDF/B");
|
||||
boolean isPdfSECCompliant = checkForStandard(document, "PDF/SEC");
|
||||
boolean isPdfA = false;
|
||||
boolean isPdfUA = false;
|
||||
boolean isPdfX = false;
|
||||
boolean isPdfE = false;
|
||||
boolean isPdfB = false;
|
||||
String pdfAConformanceLevel = null;
|
||||
|
||||
compliancy.put("IsPDF/ACompliant", isPdfACompliant);
|
||||
if (pdfaConformanceLevel != null) {
|
||||
compliancy.put("PDF/AConformanceLevel", pdfaConformanceLevel);
|
||||
Boolean pdfaValidationPassed =
|
||||
validatePdfAWithPreflight(document, pdfaConformanceLevel);
|
||||
compliancy.put("IsPDF/AValidated", pdfaValidationPassed);
|
||||
if (verificationResults != null) {
|
||||
for (PDFVerificationResult result : verificationResults) {
|
||||
if (result == null) continue;
|
||||
if (result.isCompliant()) {
|
||||
String std = result.getStandard().toLowerCase();
|
||||
if (std.contains("pdf_a") || std.contains("pdfa")) {
|
||||
isPdfA = true;
|
||||
String profile = result.getValidationProfile();
|
||||
if (profile != null) {
|
||||
if (profile.contains("1b")
|
||||
|| profile.contains("2b")
|
||||
|| profile.contains("3b")) {
|
||||
isPdfB = true;
|
||||
}
|
||||
// Simple extraction: remove "pdfa-" prefix
|
||||
pdfAConformanceLevel = profile.replace("pdfa-", "");
|
||||
}
|
||||
}
|
||||
if (std.contains("pdf_ua") || std.contains("pdfua")) isPdfUA = true;
|
||||
if (std.contains("pdf_x") || std.contains("pdfx")) isPdfX = true;
|
||||
if (std.contains("pdf_e") || std.contains("pdfe")) isPdfE = true;
|
||||
}
|
||||
}
|
||||
compliancy.put("IsPDF/XCompliant", isPdfXCompliant);
|
||||
compliancy.put("IsPDF/ECompliant", isPdfECompliant);
|
||||
compliancy.put("IsPDF/VTCompliant", isPdfVTCompliant);
|
||||
compliancy.put("IsPDF/UACompliant", isPdfUACompliant);
|
||||
compliancy.put("IsPDF/BCompliant", isPdfBCompliant);
|
||||
compliancy.put("IsPDF/SECCompliant", isPdfSECCompliant);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error extracting compliance info: {}", e.getMessage());
|
||||
compliancy.put("IsPDF/ACompliant", isPdfA);
|
||||
compliancy.put("IsPDF/UACompliant", isPdfUA);
|
||||
compliancy.put("IsPDF/ECompliant", isPdfE);
|
||||
compliancy.put("IsPDF/VTCompliant", false); // Not currently implemented
|
||||
compliancy.put("IsPDF/BCompliant", isPdfB);
|
||||
if (pdfAConformanceLevel != null) {
|
||||
compliancy.put("PDF/AConformanceLevel", pdfAConformanceLevel);
|
||||
}
|
||||
|
||||
compliancy.put("IsPDF/SECCompliant", isSECCompliant(doc));
|
||||
|
||||
if (verificationResults != null && !verificationResults.isEmpty()) {
|
||||
// Keep original simple structure as backup or extra info
|
||||
for (PDFVerificationResult result : verificationResults) {
|
||||
if (result == null) continue;
|
||||
String standard = result.getStandard();
|
||||
boolean isCompliant = result.isCompliant();
|
||||
|
||||
if (standard != null) {
|
||||
// Avoid overwriting specific keys if collision, but here keys are distinct
|
||||
// enough
|
||||
compliancy.put(standard, isCompliant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return compliancy;
|
||||
}
|
||||
|
||||
private static boolean isSECCompliant(PDDocument doc) {
|
||||
try {
|
||||
// 1. Check Encryption
|
||||
if (doc.isEncrypted()) return false;
|
||||
|
||||
PDDocumentCatalog catalog = doc.getDocumentCatalog();
|
||||
|
||||
// 2. Check for JavaScript (Active Content)
|
||||
if (catalog.getOpenAction() instanceof PDActionJavaScript) return false;
|
||||
if (catalog.getNames() != null && catalog.getNames().getJavaScript() != null)
|
||||
return false;
|
||||
|
||||
// Check for AcroForm
|
||||
if (catalog.getAcroForm() != null) return false;
|
||||
|
||||
// 3. Check for Embedded Files
|
||||
if (catalog.getNames() != null && catalog.getNames().getEmbeddedFiles() != null)
|
||||
return false;
|
||||
|
||||
// 4. Check for External Links or Navigation Actions
|
||||
for (PDPage page : doc.getPages()) {
|
||||
for (PDAnnotation annotation : page.getAnnotations()) {
|
||||
if (annotation instanceof PDAnnotationLink) {
|
||||
PDAnnotationLink link = (PDAnnotationLink) annotation;
|
||||
PDAction action = link.getAction();
|
||||
if (action instanceof PDActionURI
|
||||
|| action instanceof PDActionLaunch
|
||||
|| action instanceof PDActionRemoteGoTo
|
||||
|| action instanceof PDActionSubmitForm) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("Error checking SEC compliance: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectNode extractEncryptionInfo(PDDocument document) {
|
||||
ObjectNode encryption = objectMapper.createObjectNode();
|
||||
|
||||
@@ -1215,7 +1060,7 @@ public class GetInfoOnPDF {
|
||||
return stats;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/get-info-on-pdf")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/get-info-on-pdf")
|
||||
@Operation(
|
||||
summary = "Get comprehensive PDF information",
|
||||
description =
|
||||
@@ -1231,6 +1076,13 @@ public class GetInfoOnPDF {
|
||||
return createErrorResponse("Invalid PDF file: " + e.getMessage());
|
||||
}
|
||||
|
||||
List<PDFVerificationResult> verificationResults = null;
|
||||
try {
|
||||
verificationResults = veraPDFService.validatePDF(inputFile.getInputStream());
|
||||
} catch (Exception e) {
|
||||
log.error("VeraPDF validation failed", e);
|
||||
}
|
||||
|
||||
boolean readonly = true;
|
||||
|
||||
try (PDDocument pdfBoxDoc = pdfDocumentFactory.load(inputFile, readonly)) {
|
||||
@@ -1239,20 +1091,13 @@ public class GetInfoOnPDF {
|
||||
ObjectNode metadata = extractMetadata(pdfBoxDoc);
|
||||
ObjectNode basicInfo = extractBasicInfo(pdfBoxDoc, inputFile.getSize());
|
||||
ObjectNode docInfoNode = extractDocumentInfo(pdfBoxDoc);
|
||||
ObjectNode compliancy = extractComplianceInfo(pdfBoxDoc);
|
||||
ObjectNode compliancy = extractComplianceInfo(pdfBoxDoc, verificationResults);
|
||||
ObjectNode encryption = extractEncryptionInfo(pdfBoxDoc);
|
||||
ObjectNode permissionsNode = extractPermissions(pdfBoxDoc);
|
||||
ObjectNode other = extractOtherInfo(pdfBoxDoc);
|
||||
ObjectNode formFieldsNode = extractFormFields(pdfBoxDoc);
|
||||
|
||||
// Generate summary data
|
||||
String pdfaConformanceLevel = getPdfAConformanceLevel(pdfBoxDoc);
|
||||
Boolean pdfaValidationPassed = null;
|
||||
if (pdfaConformanceLevel != null) {
|
||||
pdfaValidationPassed = validatePdfAWithPreflight(pdfBoxDoc, pdfaConformanceLevel);
|
||||
}
|
||||
ObjectNode summaryData =
|
||||
generatePDFSummaryData(pdfBoxDoc, pdfaConformanceLevel, pdfaValidationPassed);
|
||||
ObjectNode summaryData = generatePDFSummaryData(pdfBoxDoc, verificationResults);
|
||||
|
||||
// Extract per-page information
|
||||
ObjectNode pageInfoParent = extractPerPageInfo(pdfBoxDoc);
|
||||
|
||||
+4
-8
@@ -6,16 +6,12 @@ import java.util.List;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.verapdf.core.EncryptedPdfException;
|
||||
import org.verapdf.core.ModelParsingException;
|
||||
import org.verapdf.core.ValidationException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -23,11 +19,11 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.SPDF.model.api.security.PDFVerificationRequest;
|
||||
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
|
||||
import stirling.software.SPDF.service.VeraPDFService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.SecurityApi;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/security")
|
||||
@Tag(name = "Security", description = "Security APIs")
|
||||
@SecurityApi
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class VerifyPDFController {
|
||||
@@ -41,7 +37,7 @@ public class VerifyPDFController {
|
||||
+ "Automatically detects PDF/A, PDF/UA-1, PDF/UA-2, and WTPDF standards "
|
||||
+ "from the document's XMP metadata and validates compliance. "
|
||||
+ "Input:PDF Output:JSON Type:SISO")
|
||||
@PostMapping(value = "/verify-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(value = "/verify-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<List<PDFVerificationResult>> verifyPDF(
|
||||
@ModelAttribute PDFVerificationRequest request) {
|
||||
|
||||
|
||||
+1
-4
@@ -14,9 +14,6 @@ public class PdfToPdfARequest extends PDFFile {
|
||||
@Schema(
|
||||
description = "The output format type (PDF/A or PDF/X)",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
allowableValues = {
|
||||
"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfx", "pdfx-1",
|
||||
"pdfx-3", "pdfx-4"
|
||||
})
|
||||
allowableValues = {"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfx"})
|
||||
private String outputFormat;
|
||||
}
|
||||
|
||||
+7
@@ -39,6 +39,13 @@ public class AddPageNumbersRequest extends PDFWithPageNums {
|
||||
requiredMode = RequiredMode.NOT_REQUIRED)
|
||||
private String fontColor;
|
||||
|
||||
@Schema(
|
||||
description = "Zero-padding width for page numbers (Bates Stamping). Set to 0 to disable padding",
|
||||
minimum = "0",
|
||||
defaultValue = "0",
|
||||
requiredMode = RequiredMode.NOT_REQUIRED)
|
||||
private int zeroPad = 0;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Position: 1-9 representing positions on the page (1=top-left, 2=top-center,"
|
||||
|
||||
@@ -32,8 +32,8 @@ public class AddStampRequest extends PDFWithPageNums {
|
||||
private String alphabet = "roman";
|
||||
|
||||
@Schema(
|
||||
description = "The font size of the stamp text and image",
|
||||
defaultValue = "30",
|
||||
description = "The font size of the stamp text and image in points.",
|
||||
defaultValue = "40",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private float fontSize;
|
||||
|
||||
|
||||
+3
-2
@@ -2,6 +2,7 @@ package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyStore;
|
||||
@@ -510,7 +511,7 @@ public class CertificateValidationService {
|
||||
private byte[] downloadTrustList(String urlStr) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(urlStr);
|
||||
URL url = URI.create(urlStr).toURL();
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(10_000);
|
||||
@@ -700,7 +701,7 @@ public class CertificateValidationService {
|
||||
private byte[] downloadXml(String urlStr) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(urlStr);
|
||||
URL url = URI.create(urlStr).toURL();
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(10_000);
|
||||
|
||||
+2
-7
@@ -82,7 +82,7 @@ public class TelegramPipelineBot extends TelegramLongPollingBot {
|
||||
|
||||
@PostConstruct
|
||||
public void register() {
|
||||
if (StringUtils.isAnyBlank(getBotUsername(), getBotToken())) {
|
||||
if (StringUtils.isAnyBlank(getBotUsername(), this.telegramProperties.getBotToken())) {
|
||||
log.warn("Telegram bot disabled because botToken or botUsername is not configured");
|
||||
return;
|
||||
}
|
||||
@@ -392,7 +392,7 @@ public class TelegramPipelineBot extends TelegramLongPollingBot {
|
||||
new URI(
|
||||
"https",
|
||||
"api.telegram.org",
|
||||
"/file/bot" + getBotToken() + "/" + filePath,
|
||||
"/file/bot" + this.telegramProperties.getBotToken() + "/" + filePath,
|
||||
null);
|
||||
return uri.toURL();
|
||||
} catch (URISyntaxException e) {
|
||||
@@ -516,9 +516,4 @@ public class TelegramPipelineBot extends TelegramLongPollingBot {
|
||||
public String getBotUsername() {
|
||||
return telegramProperties.getBotUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getBotToken() {
|
||||
return telegramProperties.getBotToken();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ security:
|
||||
revocation:
|
||||
mode: none # Revocation checking mode: 'none' (disabled), 'ocsp' (OCSP only), 'crl' (CRL only), 'ocsp+crl' (OCSP with CRL fallback)
|
||||
hardFail: false # Fail validation if revocation status cannot be determined (true=strict, false=soft-fail)
|
||||
xFrameOptions: DENY # X-Frame-Options header value. Options: 'DENY' (default, prevents all framing), 'SAMEORIGIN' (allows framing from same domain), 'DISABLED' (no X-Frame-Options header sent). Note: automatically set to DISABLED when login is disabled
|
||||
|
||||
premium:
|
||||
key: 00000000-0000-0000-0000-000000000000
|
||||
@@ -202,7 +203,9 @@ system:
|
||||
name: postgres # set the name of your database. Should match the name of the database you create
|
||||
customPaths:
|
||||
pipeline:
|
||||
pipelineDir: "" # Defaults to /pipeline
|
||||
watchedFoldersDir: "" # Defaults to /pipeline/watchedFolders
|
||||
watchedFoldersDirs: [] # List of watched folder directories. Defaults to watchedFoldersDir or /pipeline/watchedFolders.
|
||||
finishedFoldersDir: "" # Defaults to /pipeline/finishedFolders
|
||||
operations:
|
||||
weasyprint: "" # Defaults to /opt/venv/bin/weasyprint
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+16
-8
@@ -85,10 +85,12 @@ class EditTableOfContentsControllerTest {
|
||||
when(mockOutlineItem.getNextSibling()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getBody());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertEquals(1, result.size());
|
||||
|
||||
Map<String, Object> bookmark = result.get(0);
|
||||
@@ -107,10 +109,12 @@ class EditTableOfContentsControllerTest {
|
||||
when(mockCatalog.getDocumentOutline()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getBody());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertTrue(result.isEmpty());
|
||||
verify(mockDocument).close();
|
||||
}
|
||||
@@ -141,10 +145,12 @@ class EditTableOfContentsControllerTest {
|
||||
when(childItem.getNextSibling()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getBody());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertEquals(1, result.size());
|
||||
|
||||
Map<String, Object> parentBookmark = result.get(0);
|
||||
@@ -177,10 +183,12 @@ class EditTableOfContentsControllerTest {
|
||||
when(mockOutlineItem.getNextSibling()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getBody());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertEquals(1, result.size());
|
||||
|
||||
Map<String, Object> bookmark = result.get(0);
|
||||
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class StampControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@InjectMocks private StampController stampController;
|
||||
|
||||
private Method processStampTextMethod;
|
||||
private Method processCustomDateFormatMethod;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws NoSuchMethodException {
|
||||
processStampTextMethod =
|
||||
StampController.class.getDeclaredMethod(
|
||||
"processStampText",
|
||||
String.class,
|
||||
int.class,
|
||||
int.class,
|
||||
String.class,
|
||||
PDDocument.class);
|
||||
processStampTextMethod.setAccessible(true);
|
||||
|
||||
processCustomDateFormatMethod =
|
||||
StampController.class.getDeclaredMethod(
|
||||
"processCustomDateFormat", String.class, LocalDateTime.class);
|
||||
processCustomDateFormatMethod.setAccessible(true);
|
||||
}
|
||||
|
||||
private String invokeProcessStampText(
|
||||
String stampText, int pageNumber, int totalPages, String filename, PDDocument document)
|
||||
throws Exception {
|
||||
try {
|
||||
return (String)
|
||||
processStampTextMethod.invoke(
|
||||
stampController, stampText, pageNumber, totalPages, filename, document);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
private String invokeProcessCustomDateFormat(String format, LocalDateTime now)
|
||||
throws Exception {
|
||||
try {
|
||||
return (String) processCustomDateFormatMethod.invoke(stampController, format, now);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Basic Variable Substitution Tests")
|
||||
class BasicVariableTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @page_number with current page")
|
||||
void testPageNumberReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Page @page_number", 5, 20, "test.pdf", null);
|
||||
assertEquals("Page 5", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @total_pages with total page count")
|
||||
void testTotalPagesReplacement() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("of @total_pages pages", 1, 100, "test.pdf", null);
|
||||
assertEquals("of 100 pages", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace combined page variables")
|
||||
void testCombinedPageVariables() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText(
|
||||
"Page @page_number of @total_pages", 5, 20, "test.pdf", null);
|
||||
assertEquals("Page 5 of 20", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @page alias")
|
||||
void testPageAlias() throws Exception {
|
||||
String result = invokeProcessStampText("Page @page", 7, 10, "test.pdf", null);
|
||||
assertEquals("Page 7", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @page_count alias for total pages")
|
||||
void testPageCountAlias() throws Exception {
|
||||
String result = invokeProcessStampText("Total: @page_count", 1, 50, "test.pdf", null);
|
||||
assertEquals("Total: 50", result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Filename Variable Tests")
|
||||
class FilenameTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @filename with filename without extension")
|
||||
void testFilenameWithoutExtension() throws Exception {
|
||||
String result = invokeProcessStampText("File: @filename", 1, 1, "document.pdf", null);
|
||||
assertEquals("File: document", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @filename_full with full filename")
|
||||
void testFilenameWithExtension() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("File: @filename_full", 1, 1, "document.pdf", null);
|
||||
assertEquals("File: document.pdf", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle filename without extension")
|
||||
void testFilenameWithoutDot() throws Exception {
|
||||
String result = invokeProcessStampText("@filename", 1, 1, "document", null);
|
||||
assertEquals("document", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null filename")
|
||||
void testNullFilename() throws Exception {
|
||||
String result = invokeProcessStampText("File: @filename", 1, 1, null, null);
|
||||
assertEquals("File: ", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle filename with multiple dots")
|
||||
void testFilenameMultipleDots() throws Exception {
|
||||
String result = invokeProcessStampText("@filename", 1, 1, "my.document.v2.pdf", null);
|
||||
assertEquals("my.document.v2", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle hidden file (starts with dot)")
|
||||
void testHiddenFile() throws Exception {
|
||||
String result = invokeProcessStampText("@filename", 1, 1, ".hidden.pdf", null);
|
||||
assertEquals(".hidden", result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Date/Time Variable Tests")
|
||||
class DateTimeTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @date with current date")
|
||||
void testDateReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Date: @date", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("Date: \\d{4}-\\d{2}-\\d{2}"),
|
||||
"Date should match YYYY-MM-DD format");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @time with current time")
|
||||
void testTimeReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Time: @time", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("Time: \\d{2}:\\d{2}:\\d{2}"),
|
||||
"Time should match HH:mm:ss format");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @datetime with combined date and time")
|
||||
void testDateTimeReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("@datetime", 1, 1, "test.pdf", null);
|
||||
// DateTime format: YYYY-MM-DD HH:mm:ss
|
||||
assertTrue(
|
||||
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"),
|
||||
"DateTime should match YYYY-MM-DD HH:mm:ss format");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @year with current year")
|
||||
void testYearReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("© @year", 1, 1, "test.pdf", null);
|
||||
int currentYear = LocalDateTime.now().getYear();
|
||||
assertEquals("© " + currentYear, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @month with zero-padded month")
|
||||
void testMonthReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Month: @month", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("Month: \\d{2}"), "Month should be zero-padded");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @day with zero-padded day")
|
||||
void testDayReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Day: @day", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("Day: \\d{2}"), "Day should be zero-padded");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Custom Date Format Tests")
|
||||
class CustomDateFormatTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle custom date format dd/MM/yyyy")
|
||||
void testCustomDateFormatSlash() throws Exception {
|
||||
String result = invokeProcessStampText("@date{dd/MM/yyyy}", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("\\d{2}/\\d{2}/\\d{4}"),
|
||||
"Should match dd/MM/yyyy format: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle custom date format with time")
|
||||
void testCustomDateFormatWithTime() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("@date{yyyy-MM-dd HH:mm}", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}"),
|
||||
"Should match yyyy-MM-dd HH:mm format: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle multiple custom date formats in same text")
|
||||
void testMultipleCustomDateFormats() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText(
|
||||
"Start: @date{dd/MM/yyyy} End: @date{yyyy}", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.contains("/"), "Should contain slash from first format");
|
||||
// Should have year twice (once with slashes, once alone)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Custom Date Format Security Tests")
|
||||
class CustomDateFormatSecurityTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should not match format that is too long - regex won't capture it")
|
||||
void testFormatTooLong() throws Exception {
|
||||
String longFormat = "y".repeat(51); // 51 chars, over the 50 char regex limit
|
||||
String result =
|
||||
invokeProcessStampText("@date{" + longFormat + "}", 1, 1, "test.pdf", null);
|
||||
// The CUSTOM_DATE_PATTERN only captures up to 50 chars, so this won't match
|
||||
// The @date part will be replaced by simple replacement, leaving {yyy...}
|
||||
assertTrue(
|
||||
result.contains("{"), "Should contain { because regex didn't match: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject format with unsafe characters - shell injection attempt")
|
||||
void testShellInjectionAttempt() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("@date{yyyy-MM-dd$(rm -rf /)}", 1, 1, "test.pdf", null);
|
||||
assertEquals("[invalid format]", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject format with unsafe characters - semicolon")
|
||||
void testSemicolonInjection() throws Exception {
|
||||
String result = invokeProcessStampText("@date{yyyy;rm}", 1, 1, "test.pdf", null);
|
||||
assertEquals("[invalid format]", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject format with unsafe characters - backticks")
|
||||
void testBacktickInjection() throws Exception {
|
||||
String result = invokeProcessStampText("@date{`whoami`}", 1, 1, "test.pdf", null);
|
||||
assertEquals("[invalid format]", result);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"$(cmd)", "`cmd`", ";cmd", "|cmd", "&cmd", "<cmd", ">cmd"})
|
||||
@DisplayName("Should reject various injection attempts")
|
||||
void testVariousInjectionAttempts(String injection) throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("@date{yyyy" + injection + "}", 1, 1, "test.pdf", null);
|
||||
assertEquals("[invalid format]", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should accept valid format characters")
|
||||
void testValidFormatCharacters() throws Exception {
|
||||
// All these should be valid based on SAFE_DATE_FORMAT_PATTERN: yMdHhmsS/-:.,
|
||||
// '+EGuwWDFzZXa and space
|
||||
String result =
|
||||
invokeProcessStampText("@date{yyyy-MM-dd HH:mm:ss}", 1, 1, "test.pdf", null);
|
||||
assertFalse(
|
||||
result.startsWith("[invalid"), "Valid format should be accepted: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle invalid DateTimeFormatter pattern gracefully")
|
||||
void testInvalidFormatterPattern() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
// Use 'sssss' - too many seconds digits will throw IllegalArgumentException from
|
||||
// DateTimeFormatter
|
||||
// Note: The pattern 'sssss' passes the SAFE_DATE_FORMAT_PATTERN but fails
|
||||
// DateTimeFormatter.ofPattern()
|
||||
String result = invokeProcessCustomDateFormat("sssss", now);
|
||||
assertTrue(
|
||||
result.startsWith("[invalid format:"),
|
||||
"Invalid pattern should return error message: " + result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Escape Sequence Tests")
|
||||
class EscapeSequenceTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should convert @@ to literal @")
|
||||
void testDoubleAtEscape() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("Email: test@@example.com", 1, 1, "test.pdf", null);
|
||||
assertEquals("Email: test@example.com", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should preserve @@ before variable")
|
||||
void testEscapeBeforeVariable() throws Exception {
|
||||
String result = invokeProcessStampText("@@date is @date", 1, 1, "test.pdf", null);
|
||||
// @@date should become @date, and @date should be replaced with actual date
|
||||
assertTrue(result.startsWith("@date is "), "Should start with literal @date");
|
||||
assertTrue(
|
||||
result.matches("@date is \\d{4}-\\d{2}-\\d{2}"),
|
||||
"Should have date after: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle multiple escape sequences")
|
||||
void testMultipleEscapes() throws Exception {
|
||||
String result = invokeProcessStampText("@@one @@two @@three", 1, 1, "test.pdf", null);
|
||||
assertEquals("@one @two @three", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle escape at end of string")
|
||||
void testEscapeAtEnd() throws Exception {
|
||||
String result = invokeProcessStampText("Contact: user@@", 1, 1, "test.pdf", null);
|
||||
assertEquals("Contact: user@", result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Document Metadata Tests")
|
||||
class DocumentMetadataTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @author with document author")
|
||||
void testAuthorReplacement() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDDocumentInformation info = new PDDocumentInformation();
|
||||
info.setAuthor("John Doe");
|
||||
doc.setDocumentInformation(info);
|
||||
|
||||
try {
|
||||
String result = invokeProcessStampText("Author: @author", 1, 1, "test.pdf", doc);
|
||||
assertEquals("Author: John Doe", result);
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @title with document title")
|
||||
void testTitleReplacement() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDDocumentInformation info = new PDDocumentInformation();
|
||||
info.setTitle("My Document Title");
|
||||
doc.setDocumentInformation(info);
|
||||
|
||||
try {
|
||||
String result = invokeProcessStampText("Title: @title", 1, 1, "test.pdf", doc);
|
||||
assertEquals("Title: My Document Title", result);
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @subject with document subject")
|
||||
void testSubjectReplacement() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDDocumentInformation info = new PDDocumentInformation();
|
||||
info.setSubject("Important Subject");
|
||||
doc.setDocumentInformation(info);
|
||||
|
||||
try {
|
||||
String result = invokeProcessStampText("Subject: @subject", 1, 1, "test.pdf", doc);
|
||||
assertEquals("Subject: Important Subject", result);
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null metadata gracefully")
|
||||
void testNullMetadata() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
// Don't set any document information
|
||||
|
||||
try {
|
||||
String result =
|
||||
invokeProcessStampText("@author @title @subject", 1, 1, "test.pdf", doc);
|
||||
assertEquals(" ", result); // All should be empty strings
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null document gracefully")
|
||||
void testNullDocument() throws Exception {
|
||||
String result = invokeProcessStampText("Author: @author", 1, 1, "test.pdf", null);
|
||||
assertEquals("Author: ", result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("UUID Variable Tests")
|
||||
class UuidTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should generate 8-character UUID")
|
||||
void testUuidLength() throws Exception {
|
||||
String result = invokeProcessStampText("ID: @uuid", 1, 1, "test.pdf", null);
|
||||
// UUID format: "ID: " + 8 chars
|
||||
assertEquals(12, result.length(), "Should be 'ID: ' + 8 char UUID");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should generate different UUIDs for each call")
|
||||
void testUuidUniqueness() throws Exception {
|
||||
String result1 = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
|
||||
String result2 = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
|
||||
assertNotEquals(result1, result2, "UUIDs should be unique");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("UUID should contain only hex characters")
|
||||
void testUuidFormat() throws Exception {
|
||||
String result = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("[0-9a-f]{8}"), "UUID should be 8 hex characters: " + result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Edge Cases and Error Handling")
|
||||
class EdgeCaseTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null stamp text")
|
||||
void testNullStampText() throws Exception {
|
||||
String result = invokeProcessStampText(null, 1, 1, "test.pdf", null);
|
||||
assertEquals("", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle empty stamp text")
|
||||
void testEmptyStampText() throws Exception {
|
||||
String result = invokeProcessStampText("", 1, 1, "test.pdf", null);
|
||||
assertEquals("", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle text with no variables")
|
||||
void testNoVariables() throws Exception {
|
||||
String result = invokeProcessStampText("Just plain text", 1, 1, "test.pdf", null);
|
||||
assertEquals("Just plain text", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle unknown variables")
|
||||
void testUnknownVariable() throws Exception {
|
||||
String result = invokeProcessStampText("@unknown_var", 1, 1, "test.pdf", null);
|
||||
assertEquals("@unknown_var", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should preserve text around variables")
|
||||
void testPreservesSurroundingText() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("Before @page_number After", 5, 10, "test.pdf", null);
|
||||
assertEquals("Before 5 After", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle multiple same variables")
|
||||
void testMultipleSameVariables() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("@page_number / @page_number", 3, 10, "test.pdf", null);
|
||||
assertEquals("3 / 3", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle variables adjacent to each other")
|
||||
void testAdjacentVariables() throws Exception {
|
||||
String result = invokeProcessStampText("@page@page_number", 5, 10, "test.pdf", null);
|
||||
// @page should be replaced first (it's in the order), then @page_number
|
||||
// Since @page_number is longer and comes first in replace chain, should work
|
||||
assertEquals("55", result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Complex Scenario Tests")
|
||||
class ComplexScenarioTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle legal footer template")
|
||||
void testLegalFooterTemplate() throws Exception {
|
||||
String template = "© @year - All Rights Reserved\\n@filename - Page @page_number";
|
||||
String result = invokeProcessStampText(template, 3, 15, "contract.pdf", null);
|
||||
|
||||
int year = LocalDateTime.now().getYear();
|
||||
String expected = "© " + year + " - All Rights Reserved\\ncontract - Page 3";
|
||||
assertEquals(expected, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle Brazilian date format template")
|
||||
void testBrazilianDateFormat() throws Exception {
|
||||
String template = "Documento criado em @date{dd/MM/yyyy} às @time";
|
||||
String result = invokeProcessStampText(template, 1, 1, "doc.pdf", null);
|
||||
|
||||
assertTrue(result.startsWith("Documento criado em "));
|
||||
assertTrue(result.contains("/"));
|
||||
assertTrue(result.contains(":"));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"'Page @page_number of @total_pages', 1, 10, 'Page 1 of 10'",
|
||||
"'Page @page_number of @total_pages', 5, 20, 'Page 5 of 20'",
|
||||
"'Page @page_number of @total_pages', 100, 1000, 'Page 100 of 1000'"
|
||||
})
|
||||
@DisplayName("Should handle page number template with various values")
|
||||
void testPageNumberTemplates(String template, int page, int total, String expected)
|
||||
throws Exception {
|
||||
String result = invokeProcessStampText(template, page, total, "test.pdf", null);
|
||||
assertEquals(expected, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
-41
@@ -1,22 +1,30 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.GregorianCalendar;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.*;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
|
||||
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
|
||||
import org.apache.pdfbox.pdmodel.encryption.ProtectionPolicy;
|
||||
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.PDActionLaunch;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
@@ -35,6 +43,8 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
|
||||
import stirling.software.SPDF.service.VeraPDFService;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@@ -43,6 +53,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
class GetInfoOnPDFTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private VeraPDFService veraPDFService;
|
||||
|
||||
@InjectMocks private GetInfoOnPDF getInfoOnPDF;
|
||||
|
||||
@@ -661,41 +672,6 @@ class GetInfoOnPDFTest {
|
||||
void testGetPageSize(float width, float height, String expected) {
|
||||
Assertions.assertEquals(expected, GetInfoOnPDF.getPageSize(width, height));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should check for PDF/A standard")
|
||||
void testCheckForStandard_PdfA() throws IOException {
|
||||
// This would require a real PDF/A document or mocking
|
||||
PDDocument document = createSimplePdfWithText("Test");
|
||||
boolean result = GetInfoOnPDF.checkForStandard(document, "PDF/A");
|
||||
Assertions.assertFalse(result); // Simple PDF is not PDF/A compliant
|
||||
document.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null document in checkForStandard")
|
||||
void testCheckForStandard_NullDocument() {
|
||||
boolean result = GetInfoOnPDF.checkForStandard(null, "PDF/A");
|
||||
Assertions.assertFalse(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should get PDF/A conformance level")
|
||||
void testGetPdfAConformanceLevel() throws IOException {
|
||||
PDDocument document = createSimplePdfWithText("Test");
|
||||
String level = GetInfoOnPDF.getPdfAConformanceLevel(document);
|
||||
Assertions.assertNull(level);
|
||||
document.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle encrypted document in getPdfAConformanceLevel")
|
||||
void testGetPdfAConformanceLevel_EncryptedDocument() throws IOException {
|
||||
PDDocument document = createEncryptedPdf();
|
||||
String level = GetInfoOnPDF.getPdfAConformanceLevel(document);
|
||||
Assertions.assertNull(level); // Encrypted documents return null
|
||||
document.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -776,8 +752,8 @@ class GetInfoOnPDFTest {
|
||||
class ComplianceTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should check PDF/A compliance")
|
||||
void testCompliance_PdfA() throws IOException {
|
||||
@DisplayName("Should extract compliance info using VeraPDF")
|
||||
void testCompliance_PdfA() throws Exception {
|
||||
PDDocument document = createSimplePdfWithText("Test PDF/A");
|
||||
MockMultipartFile mockFile = documentToMultipartFile(document, "pdfa.pdf");
|
||||
|
||||
@@ -791,16 +767,22 @@ class GetInfoOnPDFTest {
|
||||
ArgumentMatchers.anyBoolean()))
|
||||
.thenReturn(loadedDoc);
|
||||
|
||||
// Mock VeraPDFService
|
||||
PDFVerificationResult result = new PDFVerificationResult();
|
||||
result.setStandard("pdfa-1b");
|
||||
result.setCompliant(true);
|
||||
result.setComplianceSummary("PDF/A-1b compliant");
|
||||
Mockito.when(veraPDFService.validatePDF(ArgumentMatchers.any(InputStream.class)))
|
||||
.thenReturn(List.of(result));
|
||||
|
||||
ResponseEntity<byte[]> response = getInfoOnPDF.getPdfInfo(request);
|
||||
|
||||
String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8);
|
||||
JsonNode jsonNode = objectMapper.readTree(jsonResponse);
|
||||
JsonNode compliancy = jsonNode.get("Compliancy");
|
||||
|
||||
Assertions.assertTrue(compliancy.has("IsPDF/ACompliant"));
|
||||
Assertions.assertTrue(compliancy.has("IsPDF/XCompliant"));
|
||||
Assertions.assertTrue(compliancy.has("IsPDF/ECompliant"));
|
||||
Assertions.assertTrue(compliancy.has("IsPDF/UACompliant"));
|
||||
Assertions.assertTrue(compliancy.has("pdfa-1b"));
|
||||
Assertions.assertTrue(compliancy.get("pdfa-1b").asBoolean());
|
||||
|
||||
loadedDoc.close();
|
||||
}
|
||||
@@ -840,4 +822,104 @@ class GetInfoOnPDFTest {
|
||||
loadedDoc.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SEC Compliance: Clean document should pass")
|
||||
void testSecCompliance_Clean() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
checkSecCompliance(doc, true);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SEC Compliance: JavaScript action should fail")
|
||||
void testSecCompliance_JavaScript() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
PDActionJavaScript jsAction = new PDActionJavaScript("app.alert('Hi')");
|
||||
doc.getDocumentCatalog().setOpenAction(jsAction);
|
||||
checkSecCompliance(doc, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SEC Compliance: External URI Link should fail")
|
||||
void testSecCompliance_ExternalLink() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
|
||||
PDAnnotationLink link = new PDAnnotationLink();
|
||||
PDActionURI action = new PDActionURI();
|
||||
action.setURI("http://google.com");
|
||||
link.setAction(action);
|
||||
|
||||
page.getAnnotations().add(link);
|
||||
checkSecCompliance(doc, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SEC Compliance: Launch Action should fail")
|
||||
void testSecCompliance_LaunchAction() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
|
||||
PDAnnotationLink link = new PDAnnotationLink();
|
||||
PDActionLaunch action = new PDActionLaunch();
|
||||
link.setAction(action);
|
||||
|
||||
page.getAnnotations().add(link);
|
||||
checkSecCompliance(doc, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SEC Compliance: Embedded File should fail")
|
||||
void testSecCompliance_EmbeddedFile() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
|
||||
PDComplexFileSpecification fs = new PDComplexFileSpecification();
|
||||
fs.setFile("test.txt");
|
||||
PDEmbeddedFile ef =
|
||||
new PDEmbeddedFile(doc, new ByteArrayInputStream("test".getBytes()));
|
||||
fs.setEmbeddedFile(ef);
|
||||
|
||||
PDEmbeddedFilesNameTreeNode efTree = new PDEmbeddedFilesNameTreeNode();
|
||||
efTree.setNames(Collections.singletonMap("test", fs));
|
||||
|
||||
PDDocumentNameDictionary names = new PDDocumentNameDictionary(doc.getDocumentCatalog());
|
||||
names.setEmbeddedFiles(efTree);
|
||||
doc.getDocumentCatalog().setNames(names);
|
||||
|
||||
checkSecCompliance(doc, false);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkSecCompliance(PDDocument doc, boolean expected) throws Exception {
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
doc.save(baos);
|
||||
byte[] bytes = baos.toByteArray();
|
||||
|
||||
Mockito.when(
|
||||
pdfDocumentFactory.load(
|
||||
ArgumentMatchers.any(MultipartFile.class),
|
||||
ArgumentMatchers.anyBoolean()))
|
||||
.thenReturn(Loader.loadPDF(bytes));
|
||||
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(
|
||||
new MockMultipartFile("file", "test.pdf", "application/pdf", bytes));
|
||||
ResponseEntity<byte[]> response = getInfoOnPDF.getPdfInfo(request);
|
||||
|
||||
String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8);
|
||||
JsonNode jsonNode = objectMapper.readTree(jsonResponse);
|
||||
boolean actual = jsonNode.get("Compliancy").get("IsPDF/SECCompliant").asBoolean();
|
||||
|
||||
Assertions.assertEquals(expected, actual, "SEC Compliance check failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
repositories {
|
||||
maven { url "https://build.shibboleth.net/maven/releases" }
|
||||
maven { url = "https://build.shibboleth.net/maven/releases" }
|
||||
}
|
||||
|
||||
ext {
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ public class AuditDashboardController {
|
||||
@Schema(
|
||||
description = "Number of days to look back for audit events",
|
||||
example = "7",
|
||||
required = true)
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@RequestParam(value = "days", defaultValue = "7")
|
||||
int days) {
|
||||
|
||||
|
||||
+35
-13
@@ -49,6 +49,7 @@ import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
@@ -68,6 +69,7 @@ public class ProprietaryUIDataController {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final MfaService mfaService;
|
||||
|
||||
public ProprietaryUIDataController(
|
||||
ApplicationProperties applicationProperties,
|
||||
@@ -80,7 +82,8 @@ public class ProprietaryUIDataController {
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("runningEE") boolean runningEE,
|
||||
UserLicenseSettingsService licenseSettingsService,
|
||||
PersistentAuditEventRepository auditRepository) {
|
||||
PersistentAuditEventRepository auditRepository,
|
||||
MfaService mfaService) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.auditConfig = auditConfig;
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
@@ -92,6 +95,7 @@ public class ProprietaryUIDataController {
|
||||
this.runningEE = runningEE;
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
this.auditRepository = auditRepository;
|
||||
this.mfaService = mfaService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,12 +249,14 @@ public class ProprietaryUIDataController {
|
||||
|
||||
Map<String, Boolean> userSessions = new HashMap<>();
|
||||
Map<String, Date> userLastRequest = new HashMap<>();
|
||||
Map<String, Map<String, String>> userSettings = new HashMap<>();
|
||||
int activeUsers = 0;
|
||||
int disabledUsers = 0;
|
||||
|
||||
while (iterator.hasNext()) {
|
||||
User user = iterator.next();
|
||||
if (user != null) {
|
||||
String username = user.getUsername();
|
||||
boolean shouldRemove = false;
|
||||
|
||||
// Check if user is an INTERNAL_API_USER
|
||||
@@ -264,7 +270,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
// Check if user is part of the Internal team
|
||||
if (user.getTeam() != null
|
||||
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
|
||||
shouldRemove = true;
|
||||
}
|
||||
|
||||
@@ -278,7 +284,7 @@ public class ProprietaryUIDataController {
|
||||
boolean hasActiveSession = false;
|
||||
Date lastRequest = null;
|
||||
Optional<SessionEntity> latestSession =
|
||||
sessionPersistentRegistry.findLatestSession(user.getUsername());
|
||||
sessionPersistentRegistry.findLatestSession(username);
|
||||
|
||||
if (latestSession.isPresent()) {
|
||||
SessionEntity sessionEntity = latestSession.get();
|
||||
@@ -299,8 +305,21 @@ public class ProprietaryUIDataController {
|
||||
lastRequest = new Date(0);
|
||||
}
|
||||
|
||||
userSessions.put(user.getUsername(), hasActiveSession);
|
||||
userLastRequest.put(user.getUsername(), lastRequest);
|
||||
User userWithSettings =
|
||||
userRepository.findByIdWithSettings(user.getId()).orElse(user);
|
||||
|
||||
// Mask mfaSecret if present in settings
|
||||
Map<String, String> originalSettings = userWithSettings.getSettings();
|
||||
Map<String, String> settingsCopy =
|
||||
originalSettings != null
|
||||
? new HashMap<>(originalSettings)
|
||||
: new HashMap<>();
|
||||
if (settingsCopy.containsKey("mfaSecret")) {
|
||||
settingsCopy.put("mfaSecret", "********");
|
||||
}
|
||||
userSettings.put(username, settingsCopy);
|
||||
userSessions.put(username, hasActiveSession);
|
||||
userLastRequest.put(username, lastRequest);
|
||||
|
||||
if (hasActiveSession) activeUsers++;
|
||||
if (!user.isEnabled()) disabledUsers++;
|
||||
@@ -329,7 +348,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
List<Team> allTeams =
|
||||
teamRepository.findAll().stream()
|
||||
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
|
||||
.filter(team -> !TeamService.INTERNAL_TEAM_NAME.equals(team.getName()))
|
||||
.toList();
|
||||
|
||||
// Calculate license limits
|
||||
@@ -356,6 +375,7 @@ public class ProprietaryUIDataController {
|
||||
data.setLicenseMaxUsers(licenseMaxUsers);
|
||||
data.setPremiumEnabled(premiumEnabled);
|
||||
data.setMailEnabled(applicationProperties.getMail().isEnabled());
|
||||
data.setUserSettings(userSettings);
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
@@ -407,6 +427,8 @@ public class ProprietaryUIDataController {
|
||||
data.setChangeCredsFlag(user.get().isFirstLogin() || user.get().isForcePasswordChange());
|
||||
data.setOAuth2Login(isOAuth2Login);
|
||||
data.setSaml2Login(isSaml2Login);
|
||||
data.setMfaEnabled(mfaService.isMfaEnabled(user.get()));
|
||||
data.setMfaRequired(mfaService.isMfaRequired(user.get()));
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
@@ -418,7 +440,7 @@ public class ProprietaryUIDataController {
|
||||
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
|
||||
List<TeamWithUserCountDTO> teamsWithCounts =
|
||||
allTeamsWithCounts.stream()
|
||||
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
|
||||
.filter(team -> !TeamService.INTERNAL_TEAM_NAME.equals(team.getName()))
|
||||
.toList();
|
||||
|
||||
List<Object[]> teamActivities = sessionRepository.findLatestActivityByTeam();
|
||||
@@ -446,7 +468,7 @@ public class ProprietaryUIDataController {
|
||||
.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Team not found"));
|
||||
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
|
||||
@@ -459,11 +481,8 @@ public class ProprietaryUIDataController {
|
||||
(user.getTeam() == null
|
||||
|| !user.getTeam().getId().equals(id))
|
||||
&& (user.getTeam() == null
|
||||
|| !user.getTeam()
|
||||
.getName()
|
||||
.equals(
|
||||
TeamService
|
||||
.INTERNAL_TEAM_NAME)))
|
||||
|| !TeamService.INTERNAL_TEAM_NAME.equals(
|
||||
user.getTeam().getName())))
|
||||
.toList();
|
||||
|
||||
List<Object[]> userSessions = sessionRepository.findLatestSessionByTeamId(id);
|
||||
@@ -541,6 +560,7 @@ public class ProprietaryUIDataController {
|
||||
private int licenseMaxUsers;
|
||||
private boolean premiumEnabled;
|
||||
private boolean mailEnabled;
|
||||
private Map<String, Map<String, String>> userSettings;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -551,6 +571,8 @@ public class ProprietaryUIDataController {
|
||||
private boolean changeCredsFlag;
|
||||
private boolean oAuth2Login;
|
||||
private boolean saml2Login;
|
||||
private boolean mfaEnabled;
|
||||
private boolean mfaRequired;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+2
-1
@@ -33,7 +33,8 @@ public class PersistentAuditEvent {
|
||||
private String principal;
|
||||
private String type;
|
||||
|
||||
@Lob private String data; // JSON blob
|
||||
@Column(columnDefinition = "text")
|
||||
private String data; // JSON blob
|
||||
|
||||
private Instant timestamp;
|
||||
}
|
||||
|
||||
+25
-10
@@ -19,6 +19,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
@@ -113,8 +114,14 @@ public class InitialSecuritySetup {
|
||||
&& userService.findByUsernameIgnoreCase(initialUsername).isEmpty()) {
|
||||
|
||||
Team team = teamService.getOrCreateDefaultTeam();
|
||||
userService.saveUser(
|
||||
initialUsername, initialPassword, team, Role.ADMIN.getRoleId(), false);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(initialUsername)
|
||||
.password(initialPassword)
|
||||
.team(team)
|
||||
.role(Role.ADMIN.getRoleId())
|
||||
.firstLogin(false);
|
||||
userService.saveUserCore(builder.build());
|
||||
log.info("Admin user created: {}", initialUsername);
|
||||
} else {
|
||||
createDefaultAdminUser();
|
||||
@@ -127,8 +134,14 @@ public class InitialSecuritySetup {
|
||||
|
||||
if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) {
|
||||
Team team = teamService.getOrCreateDefaultTeam();
|
||||
userService.saveUser(
|
||||
defaultUsername, defaultPassword, team, Role.ADMIN.getRoleId(), true);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(defaultUsername)
|
||||
.password(defaultPassword)
|
||||
.team(team)
|
||||
.role(Role.ADMIN.getRoleId())
|
||||
.firstLogin(true);
|
||||
userService.saveUserCore(builder.build());
|
||||
log.info("Default admin user created: {}", defaultUsername);
|
||||
}
|
||||
}
|
||||
@@ -137,12 +150,14 @@ public class InitialSecuritySetup {
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
if (!userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) {
|
||||
Team team = teamService.getOrCreateInternalTeam();
|
||||
userService.saveUser(
|
||||
Role.INTERNAL_API_USER.getRoleId(),
|
||||
UUID.randomUUID().toString(),
|
||||
team,
|
||||
Role.INTERNAL_API_USER.getRoleId(),
|
||||
false);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(Role.INTERNAL_API_USER.getRoleId())
|
||||
.password(UUID.randomUUID().toString())
|
||||
.team(team)
|
||||
.role(Role.INTERNAL_API_USER.getRoleId())
|
||||
.firstLogin(false);
|
||||
userService.saveUserCore(builder.build());
|
||||
userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId());
|
||||
log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId());
|
||||
} else {
|
||||
|
||||
+54
-3
@@ -8,6 +8,7 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.authentication.ProviderManager;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
@@ -185,11 +186,39 @@ public class SecurityConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
public SecurityFilterChain samlFilterChain(
|
||||
HttpSecurity http,
|
||||
@Lazy IPRateLimitingFilter rateLimitingFilter,
|
||||
@Lazy JwtAuthenticationFilter jwtAuthenticationFilter)
|
||||
throws Exception {
|
||||
http.securityMatcher("/saml2/**", "/login/saml2/**");
|
||||
|
||||
SessionCreationPolicy sessionPolicy =
|
||||
(securityProperties.isSaml2Active() && runningProOrHigher)
|
||||
? SessionCreationPolicy.IF_REQUIRED
|
||||
: SessionCreationPolicy.STATELESS;
|
||||
|
||||
return configureSecurity(http, rateLimitingFilter, jwtAuthenticationFilter, sessionPolicy);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(2)
|
||||
public SecurityFilterChain filterChain(
|
||||
HttpSecurity http,
|
||||
@Lazy IPRateLimitingFilter rateLimitingFilter,
|
||||
@Lazy JwtAuthenticationFilter jwtAuthenticationFilter)
|
||||
throws Exception {
|
||||
SessionCreationPolicy sessionPolicy = SessionCreationPolicy.STATELESS;
|
||||
return configureSecurity(http, rateLimitingFilter, jwtAuthenticationFilter, sessionPolicy);
|
||||
}
|
||||
|
||||
private SecurityFilterChain configureSecurity(
|
||||
HttpSecurity http,
|
||||
@Lazy IPRateLimitingFilter rateLimitingFilter,
|
||||
@Lazy JwtAuthenticationFilter jwtAuthenticationFilter,
|
||||
SessionCreationPolicy sessionPolicy)
|
||||
throws Exception {
|
||||
// Enable CORS only if we have configured origins
|
||||
CorsConfigurationSource corsSource = corsConfigurationSource();
|
||||
if (corsSource != null) {
|
||||
@@ -201,6 +230,30 @@ public class SecurityConfiguration {
|
||||
|
||||
http.csrf(CsrfConfigurer::disable);
|
||||
|
||||
// Configure X-Frame-Options based on settings.yml configuration
|
||||
// When login is disabled, automatically disable X-Frame-Options to allow embedding
|
||||
if (!loginEnabledValue) {
|
||||
http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable()));
|
||||
} else {
|
||||
String xFrameOption = securityProperties.getXFrameOptions();
|
||||
if (xFrameOption != null) {
|
||||
http.headers(
|
||||
headers -> {
|
||||
if ("DISABLED".equalsIgnoreCase(xFrameOption)) {
|
||||
headers.frameOptions(frameOptions -> frameOptions.disable());
|
||||
} else if ("SAMEORIGIN".equalsIgnoreCase(xFrameOption)) {
|
||||
headers.frameOptions(frameOptions -> frameOptions.sameOrigin());
|
||||
} else {
|
||||
// Default to DENY
|
||||
headers.frameOptions(frameOptions -> frameOptions.deny());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// If not configured, use default DENY
|
||||
http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.deny()));
|
||||
}
|
||||
}
|
||||
|
||||
if (loginEnabledValue) {
|
||||
|
||||
http.addFilterBefore(
|
||||
@@ -209,9 +262,7 @@ public class SecurityConfiguration {
|
||||
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
|
||||
|
||||
http.sessionManagement(
|
||||
sessionManagement ->
|
||||
sessionManagement.sessionCreationPolicy(
|
||||
SessionCreationPolicy.STATELESS));
|
||||
sessionManagement -> sessionManagement.sessionCreationPolicy(sessionPolicy));
|
||||
http.authenticationProvider(daoAuthenticationProvider());
|
||||
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
|
||||
|
||||
|
||||
+55
@@ -186,6 +186,13 @@ public class AdminSettingsController {
|
||||
+ HtmlUtils.htmlEscape(key)));
|
||||
}
|
||||
|
||||
// Validate pipeline path settings
|
||||
String validationError = validatePipelinePathSetting(key, value);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", HtmlUtils.htmlEscape(validationError)));
|
||||
}
|
||||
|
||||
log.info("Admin updating setting: {} = {}", key, value);
|
||||
GeneralUtils.saveKeyToSettings(key, value);
|
||||
|
||||
@@ -642,6 +649,54 @@ public class AdminSettingsController {
|
||||
return true;
|
||||
}
|
||||
|
||||
private String validatePipelinePathSetting(String key, Object value) {
|
||||
// Validate pipeline path settings
|
||||
if (key.startsWith("system.customPaths.pipeline.watchedFoldersDirs")
|
||||
&& value instanceof java.util.List) {
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<String> paths = (java.util.List<String>) value;
|
||||
|
||||
// Check for empty or all-blank paths
|
||||
if (paths.isEmpty()) {
|
||||
return null; // Empty is OK, will use default
|
||||
}
|
||||
|
||||
// Validate each path
|
||||
java.util.Set<String> normalizedPaths = new java.util.HashSet<>();
|
||||
for (String path : paths) {
|
||||
if (path != null && !path.trim().isEmpty()) {
|
||||
try {
|
||||
java.nio.file.Path normalized =
|
||||
java.nio.file.Paths.get(path.trim()).toAbsolutePath().normalize();
|
||||
String normalizedStr = normalized.toString();
|
||||
|
||||
// Check for duplicates
|
||||
if (normalizedPaths.contains(normalizedStr)) {
|
||||
return "Duplicate path detected: " + path;
|
||||
}
|
||||
normalizedPaths.add(normalizedStr);
|
||||
} catch (java.nio.file.InvalidPathException e) {
|
||||
return "Invalid path: " + path + " - " + e.getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for overlapping paths
|
||||
java.util.List<String> pathList = new java.util.ArrayList<>(normalizedPaths);
|
||||
for (int i = 0; i < pathList.size(); i++) {
|
||||
java.nio.file.Path path1 = java.nio.file.Paths.get(pathList.get(i));
|
||||
for (int j = i + 1; j < pathList.size(); j++) {
|
||||
java.nio.file.Path path2 = java.nio.file.Paths.get(pathList.get(j));
|
||||
if (path1.startsWith(path2) || path2.startsWith(path1)) {
|
||||
return "Overlapping paths detected: " + path1 + " and " + path2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null; // Valid
|
||||
}
|
||||
|
||||
private Object getSettingByKey(String key) {
|
||||
if (key == null || key.trim().isEmpty()) {
|
||||
return null;
|
||||
|
||||
+276
-5
@@ -11,7 +11,12 @@ import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
@@ -27,10 +32,13 @@ import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.TotpService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/** REST API Controller for authentication operations. */
|
||||
@@ -45,6 +53,8 @@ public class AuthController {
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final MfaService mfaService;
|
||||
private final TotpService totpService;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
|
||||
/**
|
||||
@@ -58,7 +68,7 @@ public class AuthController {
|
||||
@PostMapping("/login")
|
||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||
public ResponseEntity<?> login(
|
||||
@RequestBody UsernameAndPass request,
|
||||
@RequestBody UsernameAndPassMfa request,
|
||||
HttpServletRequest httpRequest,
|
||||
HttpServletResponse response) {
|
||||
try {
|
||||
@@ -116,6 +126,47 @@ public class AuthController {
|
||||
.body(Map.of("error", "User account is disabled"));
|
||||
}
|
||||
|
||||
if (mfaService.isMfaEnabled(user)) {
|
||||
String code = request.getMfaCode();
|
||||
if (code == null || code.isBlank()) {
|
||||
log.warn(
|
||||
"MFA required but no code provided for user: {} from IP: {}",
|
||||
username,
|
||||
ip);
|
||||
// loginAttemptService.loginFailed(username);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(
|
||||
Map.of(
|
||||
"error", "mfa_required",
|
||||
"message", "Two-factor code required"));
|
||||
}
|
||||
String secret = mfaService.getSecret(user);
|
||||
if (secret == null || secret.isBlank()) {
|
||||
log.error("MFA enabled but no secret stored for user: {}", username);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "MFA configuration error"));
|
||||
}
|
||||
Long timeStep = totpService.getValidTimeStep(secret, code);
|
||||
if (timeStep == null) {
|
||||
log.warn("Invalid MFA code for user: {} from IP: {}", username, ip);
|
||||
loginAttemptService.loginFailed(username);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(
|
||||
Map.of(
|
||||
"error", "invalid_mfa_code",
|
||||
"message", "Invalid two-factor code"));
|
||||
}
|
||||
if (!mfaService.markTotpStepUsed(user, timeStep)) {
|
||||
log.warn("Replay MFA code detected for user: {} from IP: {}", username, ip);
|
||||
loginAttemptService.loginFailed(username);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(
|
||||
Map.of(
|
||||
"error", "invalid_mfa_code",
|
||||
"message", "Invalid two-factor code"));
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", AuthenticationType.WEB.toString());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
@@ -163,7 +214,7 @@ public class AuthController {
|
||||
|
||||
if (auth == null
|
||||
|| !auth.isAuthenticated()
|
||||
|| auth.getPrincipal().equals("anonymousUser")) {
|
||||
|| "anonymousUser".equals(auth.getPrincipal())) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
@@ -235,7 +286,10 @@ public class AuthController {
|
||||
|
||||
log.debug("Token refreshed for user: {}", username);
|
||||
|
||||
return ResponseEntity.ok(Map.of("access_token", newToken, "expires_in", 3600));
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", newToken, "expires_in", 3600)));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Token refresh error", e);
|
||||
@@ -244,6 +298,210 @@ public class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
|
||||
@GetMapping("/mfa/setup")
|
||||
public ResponseEntity<?> setupMfa(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
String username = authentication.getName();
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
|
||||
if (authTypeResponse != null) {
|
||||
return authTypeResponse;
|
||||
}
|
||||
|
||||
if (mfaService.isMfaEnabled(user)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "MFA already enabled"));
|
||||
}
|
||||
|
||||
try {
|
||||
String secret = totpService.generateSecret();
|
||||
mfaService.setSecret(user, secret);
|
||||
String otpAuthUri = totpService.buildOtpAuthUri(username, secret);
|
||||
|
||||
return ResponseEntity.ok(Map.of("secret", secret, "otpauthUri", otpAuthUri));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to setup MFA for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to setup MFA"));
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/mfa/enable")
|
||||
public ResponseEntity<?> enableMfa(
|
||||
@RequestBody MfaCodeRequest request, Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
String username = authentication.getName();
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
|
||||
if (authTypeResponse != null) {
|
||||
return authTypeResponse;
|
||||
}
|
||||
|
||||
String secret = mfaService.getSecret(user);
|
||||
if (secret == null || secret.isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "MFA setup required"));
|
||||
}
|
||||
|
||||
if (request == null || request.getCode() == null) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "MFA code is required"));
|
||||
}
|
||||
|
||||
Long timeStep = totpService.getValidTimeStep(secret, request.getCode());
|
||||
if (timeStep == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid two-factor code"));
|
||||
}
|
||||
|
||||
try {
|
||||
if (!mfaService.isTotpStepUsable(user, timeStep)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid two-factor code"));
|
||||
}
|
||||
mfaService.enableMfa(user);
|
||||
mfaService.markTotpStepUsed(user, timeStep);
|
||||
mfaService.setMfaRequired(user, false);
|
||||
return ResponseEntity.ok(Map.of("enabled", true));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to enable MFA for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to enable MFA"));
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/mfa/disable")
|
||||
public ResponseEntity<?> disableMfa(
|
||||
@RequestBody MfaCodeRequest request, Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
String username = authentication.getName();
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
|
||||
if (authTypeResponse != null) {
|
||||
return authTypeResponse;
|
||||
}
|
||||
|
||||
if (!mfaService.isMfaEnabled(user)) {
|
||||
return ResponseEntity.ok(Map.of("enabled", false));
|
||||
}
|
||||
|
||||
String secret = mfaService.getSecret(user);
|
||||
if (secret == null || secret.isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "MFA configuration missing"));
|
||||
}
|
||||
|
||||
if (request == null || request.getCode() == null) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "MFA code is required"));
|
||||
}
|
||||
|
||||
Long timeStep = totpService.getValidTimeStep(secret, request.getCode());
|
||||
if (timeStep == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid two-factor code"));
|
||||
}
|
||||
|
||||
try {
|
||||
if (!mfaService.isTotpStepUsable(user, timeStep)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid two-factor code"));
|
||||
}
|
||||
mfaService.disableMfa(user);
|
||||
mfaService.markTotpStepUsed(user, timeStep);
|
||||
return ResponseEntity.ok(Map.of("enabled", false));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to disable MFA for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to disable MFA"));
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/mfa/setup/cancel")
|
||||
public ResponseEntity<?> cancelMfaSetup(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
String username = authentication.getName();
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
if (mfaService.isMfaEnabled(user)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "MFA already enabled"));
|
||||
}
|
||||
|
||||
try {
|
||||
mfaService.clearPendingSecret(user);
|
||||
return ResponseEntity.ok(Map.of("cleared", true));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to clear MFA setup for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to clear MFA setup"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin endpoint to disable MFA for a user
|
||||
*
|
||||
* @param username Username of the user to disable MFA for
|
||||
* @return Response indicating success or failure
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/mfa/disable/admin/{username}")
|
||||
public ResponseEntity<?> disableMfaByAdmin(@PathVariable String username) {
|
||||
try {
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
if (!mfaService.isMfaEnabled(user)) {
|
||||
return ResponseEntity.ok(Map.of("enabled", false));
|
||||
}
|
||||
|
||||
mfaService.disableMfa(user);
|
||||
return ResponseEntity.ok(Map.of("enabled", false));
|
||||
} catch (UsernameNotFoundException e) {
|
||||
log.warn("User not found for MFA disable: {}", username);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found"));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to disable MFA for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to disable MFA"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build user response object
|
||||
*
|
||||
@@ -266,6 +524,19 @@ public class AuthController {
|
||||
appMetadata.put("provider", user.getAuthenticationType());
|
||||
userMap.put("app_metadata", appMetadata);
|
||||
|
||||
// Add user metadata
|
||||
Map<String, Object> userMetadata = new HashMap<>();
|
||||
userMetadata.put("firstLogin", user.isFirstLogin());
|
||||
userMap.put("user_metadata", userMetadata);
|
||||
|
||||
return userMap;
|
||||
}
|
||||
|
||||
private ResponseEntity<?> ensureWebAuth(User user) {
|
||||
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "MFA settings are only available for web accounts"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-8
@@ -23,6 +23,7 @@ import stirling.software.proprietary.security.model.InviteToken;
|
||||
import stirling.software.proprietary.security.repository.InviteTokenRepository;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@@ -90,7 +91,8 @@ public class InviteLinkController {
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"An active invite already exists for this email address"));
|
||||
"An active invite already exists for this email"
|
||||
+ " address"));
|
||||
}
|
||||
|
||||
// If sendEmail is requested but no email provided, reject
|
||||
@@ -123,7 +125,8 @@ public class InviteLinkController {
|
||||
+ (currentUserCount + activeInvites)
|
||||
+ "/"
|
||||
+ maxUsers
|
||||
+ " users). Contact your administrator to upgrade your license."));
|
||||
+ " users). Contact your administrator to"
|
||||
+ " upgrade your license."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,12 +460,13 @@ public class InviteLinkController {
|
||||
}
|
||||
|
||||
// Create the user account
|
||||
userService.saveUser(
|
||||
effectiveEmail,
|
||||
password,
|
||||
invite.getTeamId(),
|
||||
invite.getRole(),
|
||||
false); // Don't force password change
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(effectiveEmail)
|
||||
.password(password)
|
||||
.teamId(invite.getTeamId())
|
||||
.role(invite.getRole());
|
||||
userService.saveUserCore(builder.build());
|
||||
|
||||
// Mark invite as used
|
||||
invite.setUsed(true);
|
||||
|
||||
+96
-29
@@ -5,6 +5,7 @@ import java.security.Principal;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -17,7 +18,10 @@ import org.springframework.security.core.session.SessionInformation;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -42,6 +46,7 @@ import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
@@ -65,26 +70,24 @@ public class UserController {
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> register(@RequestBody UsernameAndPass usernameAndPass)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
String username = usernameAndPass.getUsername();
|
||||
String password = usernameAndPass.getPassword();
|
||||
try {
|
||||
log.debug("Registration attempt for user: {}", usernameAndPass.getUsername());
|
||||
log.debug("Registration attempt for user: {}", username);
|
||||
|
||||
if (userService.usernameExistsIgnoreCase(usernameAndPass.getUsername())) {
|
||||
log.warn(
|
||||
"Registration failed: username already exists: {}",
|
||||
usernameAndPass.getUsername());
|
||||
if (userService.usernameExistsIgnoreCase(username)) {
|
||||
log.warn("Registration failed: username already exists: {}", username);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "User already exists"));
|
||||
}
|
||||
|
||||
if (!userService.isUsernameValid(usernameAndPass.getUsername())) {
|
||||
log.warn(
|
||||
"Registration failed: invalid username format: {}",
|
||||
usernameAndPass.getUsername());
|
||||
if (!userService.isUsernameValid(username)) {
|
||||
log.warn("Registration failed: invalid username format: {}", username);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid username format"));
|
||||
}
|
||||
|
||||
if (usernameAndPass.getPassword() == null || usernameAndPass.getPassword().isEmpty()) {
|
||||
if (password == null || password.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password is required"));
|
||||
}
|
||||
@@ -101,17 +104,16 @@ public class UserController {
|
||||
+ ", Available slots: "
|
||||
+ availableSlots));
|
||||
}
|
||||
|
||||
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
User user =
|
||||
userService.saveUser(
|
||||
usernameAndPass.getUsername(),
|
||||
usernameAndPass.getPassword(),
|
||||
team,
|
||||
Role.USER.getRoleId(),
|
||||
false);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(username)
|
||||
.password(password)
|
||||
.team(team)
|
||||
.enabled(false);
|
||||
User user = userService.saveUserCore(builder.build());
|
||||
|
||||
log.info("User registered successfully: {}", usernameAndPass.getUsername());
|
||||
log.info("User registered successfully: {}", username);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(
|
||||
@@ -126,7 +128,7 @@ public class UserController {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Registration error for user: {}", usernameAndPass.getUsername(), e);
|
||||
log.error("Registration error for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Registration failed: " + e.getMessage()));
|
||||
}
|
||||
@@ -221,6 +223,7 @@ public class UserController {
|
||||
Principal principal,
|
||||
@RequestParam(name = "currentPassword") String currentPassword,
|
||||
@RequestParam(name = "newPassword") String newPassword,
|
||||
@RequestParam(name = "confirmPassword") String confirmPassword,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
@@ -233,6 +236,43 @@ public class UserController {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "userNotFound", "message", "User not found"));
|
||||
}
|
||||
|
||||
if (currentPassword == null
|
||||
|| currentPassword.isEmpty()
|
||||
|| newPassword == null
|
||||
|| newPassword.isEmpty()
|
||||
|| confirmPassword == null
|
||||
|| confirmPassword.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"missingParameters",
|
||||
"message",
|
||||
"Current password, new password, and confirmation are"
|
||||
+ " required"));
|
||||
}
|
||||
|
||||
if (!newPassword.equals(confirmPassword)) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"passwordMismatch",
|
||||
"message",
|
||||
"New password and confirmation do not match"));
|
||||
}
|
||||
|
||||
if (newPassword.equals(currentPassword)) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"passwordUnchanged",
|
||||
"message",
|
||||
"New password must be different from the current password"));
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
if (!userService.isPasswordCorrect(user, currentPassword)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
@@ -325,7 +365,9 @@ public class UserController {
|
||||
@RequestParam(name = "teamId", required = false) Long teamId,
|
||||
@RequestParam(name = "authType") String authType,
|
||||
@RequestParam(name = "forceChange", required = false, defaultValue = "false")
|
||||
boolean forceChange)
|
||||
boolean forceChange,
|
||||
@RequestParam(name = "forceMFA", required = false, defaultValue = "false")
|
||||
boolean forceMFA)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
if (!userService.isUsernameValid(username)) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
@@ -347,8 +389,9 @@ public class UserController {
|
||||
+ availableSlots));
|
||||
}
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
|
||||
User user = null;
|
||||
if (userOpt.isPresent()) {
|
||||
User user = userOpt.get();
|
||||
user = userOpt.get();
|
||||
if (user.getUsername().equalsIgnoreCase(username)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "Username already exists."));
|
||||
@@ -390,9 +433,23 @@ public class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
|
||||
userService.saveUser(username, AuthenticationType.SSO, effectiveTeamId, role);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder().username(username).teamId(effectiveTeamId).role(role);
|
||||
|
||||
AuthenticationType requestedAuthType;
|
||||
if ("SSO".equalsIgnoreCase(authType)) {
|
||||
requestedAuthType = AuthenticationType.OAUTH2;
|
||||
} else {
|
||||
try {
|
||||
requestedAuthType = AuthenticationType.valueOf(authType.toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid authentication type specified."));
|
||||
}
|
||||
}
|
||||
builder.authenticationType(requestedAuthType);
|
||||
|
||||
if (requestedAuthType == AuthenticationType.WEB) {
|
||||
if (password == null || password.isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password is required."));
|
||||
@@ -401,8 +458,9 @@ public class UserController {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password must be at least 6 characters."));
|
||||
}
|
||||
userService.saveUser(username, password, effectiveTeamId, role, forceChange);
|
||||
builder.password(password).firstLogin(forceChange).requireMfa(forceMFA);
|
||||
}
|
||||
userService.saveUserCore(builder.build());
|
||||
return ResponseEntity.ok(Map.of("message", "User created successfully"));
|
||||
}
|
||||
|
||||
@@ -427,7 +485,8 @@ public class UserController {
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Email service is not configured. Please configure SMTP settings."));
|
||||
"Email service is not configured. Please configure SMTP"
|
||||
+ " settings."));
|
||||
}
|
||||
|
||||
// Parse comma-separated email addresses
|
||||
@@ -645,7 +704,8 @@ public class UserController {
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"User's email is not a valid email address. Notifications are disabled."));
|
||||
"User's email is not a valid email address. Notifications"
|
||||
+ " are disabled."));
|
||||
}
|
||||
|
||||
String loginUrl = buildLoginUrl(request);
|
||||
@@ -826,7 +886,14 @@ public class UserController {
|
||||
String temporaryPassword = java.util.UUID.randomUUID().toString().substring(0, 12);
|
||||
|
||||
// Create user with forceChange=true
|
||||
userService.saveUser(email, temporaryPassword, teamId, role, true);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(email)
|
||||
.password(temporaryPassword)
|
||||
.teamId(teamId)
|
||||
.role(role)
|
||||
.firstLogin(true);
|
||||
userService.saveUserCore(builder.build());
|
||||
|
||||
// Send invite email
|
||||
try {
|
||||
|
||||
+3
-2
@@ -12,8 +12,9 @@ public class H2SQLCondition implements Condition {
|
||||
boolean enableCustomDatabase =
|
||||
env.getProperty("system.datasource.enableCustomDatabase", Boolean.class, false);
|
||||
|
||||
if (enableCustomDatabase) {
|
||||
return false;
|
||||
// If custom database is not enabled, H2 is used by default
|
||||
if (!enableCustomDatabase) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String dataSourceType = env.getProperty("system.datasource.type", String.class, "");
|
||||
|
||||
+16
@@ -4,6 +4,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -18,6 +19,9 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
@Query("FROM User u LEFT JOIN FETCH u.settings where upper(u.username) = upper(:username)")
|
||||
Optional<User> findByUsernameIgnoreCaseWithSettings(@Param("username") String username);
|
||||
|
||||
@Query("FROM User u LEFT JOIN FETCH u.settings where u.id = :id")
|
||||
Optional<User> findByIdWithSettings(@Param("id") Long id);
|
||||
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
Optional<User> findByApiKey(String apiKey);
|
||||
@@ -76,4 +80,16 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
"SELECT COUNT(u) FROM User u WHERE u.ssoProvider IS NOT NULL "
|
||||
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
|
||||
long countSsoUsers();
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(u) FROM User u JOIN u.settings settings "
|
||||
+ "WHERE KEY(settings) = :key AND settings = :value")
|
||||
long countUsersBySetting(@Param("key") String key, @Param("value") String value);
|
||||
|
||||
@Modifying
|
||||
@Query(
|
||||
value = "DELETE FROM user_settings WHERE user_id = :userId AND setting_key IN (:keys)",
|
||||
nativeQuery = true)
|
||||
void deleteSettingsByUserIdAndKeys(
|
||||
@Param("userId") Long userId, @Param("keys") List<String> keys);
|
||||
}
|
||||
|
||||
+21
-2
@@ -26,8 +26,27 @@ public class EnterpriseEndpointFilter extends OncePerRequestFilter {
|
||||
throws ServletException, IOException {
|
||||
|
||||
if (!runningProOrHigher && isPrometheusEndpointRequest(request)) {
|
||||
response.setStatus(HttpStatus.NOT_FOUND.value());
|
||||
return;
|
||||
// Allow only health checks to pass through for non-pro users
|
||||
String uri = request.getRequestURI();
|
||||
|
||||
// Strip the context path
|
||||
String contextPath = request.getContextPath();
|
||||
String trimmedUri =
|
||||
(contextPath != null && uri.startsWith(contextPath))
|
||||
? uri.substring(contextPath.length())
|
||||
: uri;
|
||||
|
||||
boolean isHealthCheck =
|
||||
trimmedUri.startsWith("/actuator/health")
|
||||
|| "/health".equals(trimmedUri)
|
||||
|| "/healthz".equals(trimmedUri)
|
||||
|| "/liveness".equals(trimmedUri)
|
||||
|| "/readiness".equals(trimmedUri);
|
||||
|
||||
if (!isHealthCheck) {
|
||||
response.setStatus(HttpStatus.NOT_FOUND.value());
|
||||
return;
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,6 @@ public class User implements UserDetails, Serializable {
|
||||
|
||||
@ElementCollection
|
||||
@MapKeyColumn(name = "setting_key")
|
||||
@Lob
|
||||
@Column(name = "setting_value", columnDefinition = "text")
|
||||
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@JsonIgnore
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package stirling.software.proprietary.security.model.api.user;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class MfaCodeRequest {
|
||||
|
||||
@Schema(description = "6-digit authentication code from your authenticator app")
|
||||
private String code;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.proprietary.security.model.api.user;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class UsernameAndPassMfa extends UsernameAndPass {
|
||||
|
||||
@Schema(description = "6-digit authentication code from authenticator app", example = "123456")
|
||||
private String mfaCode;
|
||||
}
|
||||
+15
-11
@@ -1,9 +1,9 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -114,10 +114,10 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
.sendRedirect(request, response, "/logout?userIsDisabled=true");
|
||||
return;
|
||||
}
|
||||
boolean isSsoUser = userService.isSsoAuthenticationTypeByUsername(username);
|
||||
if (userExists
|
||||
&& userService.hasPassword(username)
|
||||
&& (!userService.isAuthenticationTypeByUsername(username, SSO)
|
||||
|| !userService.isAuthenticationTypeByUsername(username, OAUTH2))
|
||||
&& !isSsoUser
|
||||
&& oauth2Properties.getAutoCreateUser()) {
|
||||
response.sendRedirect(contextPath + "/logout?oAuth2AuthenticationErrorWeb=true");
|
||||
return;
|
||||
@@ -259,19 +259,23 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
String referer = request.getHeader("Referer");
|
||||
if (referer != null && !referer.isEmpty()) {
|
||||
try {
|
||||
java.net.URL refererUrl = new java.net.URL(referer);
|
||||
String refererHost = refererUrl.getHost().toLowerCase();
|
||||
URI refererUri = URI.create(referer);
|
||||
String host = refererUri.getHost();
|
||||
if (host == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String refererHost = host.toLowerCase();
|
||||
|
||||
if (!isOAuthProviderDomain(refererHost)) {
|
||||
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
|
||||
if (refererUrl.getPort() != -1
|
||||
&& refererUrl.getPort() != 80
|
||||
&& refererUrl.getPort() != 443) {
|
||||
origin += ":" + refererUrl.getPort();
|
||||
String origin = refererUri.getScheme() + "://" + host;
|
||||
int port = refererUri.getPort();
|
||||
if (port != -1 && port != 80 && port != 443) {
|
||||
origin += ":" + port;
|
||||
}
|
||||
return Optional.of(origin);
|
||||
}
|
||||
} catch (java.net.MalformedURLException e) {
|
||||
} catch (IllegalArgumentException e) {
|
||||
// ignore and fall back
|
||||
}
|
||||
}
|
||||
|
||||
+16
-13
@@ -1,9 +1,9 @@
|
||||
package stirling.software.proprietary.security.saml2;
|
||||
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.SAML2;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.SQLException;
|
||||
@@ -126,8 +126,8 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
}
|
||||
|
||||
boolean hasPassword = userExists && userService.hasPassword(username);
|
||||
boolean isSSOUser =
|
||||
userExists && userService.isAuthenticationTypeByUsername(username, SSO);
|
||||
boolean isSsoUser =
|
||||
userExists && userService.isSsoAuthenticationTypeByUsername(username);
|
||||
boolean isSAML2User =
|
||||
userExists && userService.isAuthenticationTypeByUsername(username, SAML2);
|
||||
|
||||
@@ -135,15 +135,15 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
"User status - Exists: {}, Has password: {}, Is SSO user: {}, Is SAML2 user: {}",
|
||||
userExists,
|
||||
hasPassword,
|
||||
isSSOUser,
|
||||
isSsoUser,
|
||||
isSAML2User);
|
||||
|
||||
if (userExists
|
||||
&& hasPassword
|
||||
&& (!isSSOUser || !isSAML2User)
|
||||
&& !isSsoUser
|
||||
&& saml2Properties.getAutoCreateUser()) {
|
||||
log.debug(
|
||||
"User {} exists with password but is not SSO user, redirecting to logout",
|
||||
"User {} exists with password but is not an SSO user, redirecting to logout",
|
||||
username);
|
||||
String origin = resolveOrigin(request);
|
||||
response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true");
|
||||
@@ -318,15 +318,18 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
String referer = request.getHeader("Referer");
|
||||
if (referer != null && !referer.isEmpty()) {
|
||||
try {
|
||||
java.net.URL refererUrl = new java.net.URL(referer);
|
||||
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
|
||||
if (refererUrl.getPort() != -1
|
||||
&& refererUrl.getPort() != 80
|
||||
&& refererUrl.getPort() != 443) {
|
||||
origin += ":" + refererUrl.getPort();
|
||||
URI refererUri = URI.create(referer);
|
||||
String host = refererUri.getHost();
|
||||
if (host == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String origin = refererUri.getScheme() + "://" + host;
|
||||
int port = refererUri.getPort();
|
||||
if (port != -1 && port != 80 && port != 443) {
|
||||
origin += ":" + port;
|
||||
}
|
||||
return Optional.of(origin);
|
||||
} catch (java.net.MalformedURLException e) {
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.debug(
|
||||
"Malformed referer URL: {}, falling back to request-based origin", referer);
|
||||
}
|
||||
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
package stirling.software.proprietary.security.saml2;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
|
||||
@Slf4j
|
||||
public class JwtSaml2AuthenticationRequestRepository
|
||||
implements Saml2AuthenticationRequestRepository<Saml2PostAuthenticationRequest> {
|
||||
private final Map<String, String> tokenStore;
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository;
|
||||
|
||||
private static final String SAML_REQUEST_TOKEN = "stirling_saml_request_token";
|
||||
|
||||
public JwtSaml2AuthenticationRequestRepository(
|
||||
Map<String, String> tokenStore,
|
||||
JwtServiceInterface jwtService,
|
||||
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) {
|
||||
this.tokenStore = tokenStore;
|
||||
this.jwtService = jwtService;
|
||||
this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAuthenticationRequest(
|
||||
Saml2PostAuthenticationRequest authRequest,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
if (!jwtService.isJwtEnabled()) {
|
||||
log.debug("V2 is not enabled, skipping SAMLRequest token storage");
|
||||
return;
|
||||
}
|
||||
|
||||
if (authRequest == null) {
|
||||
removeAuthenticationRequest(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> claims = serializeSamlRequest(authRequest);
|
||||
String token = jwtService.generateToken("", claims);
|
||||
String relayState = authRequest.getRelayState();
|
||||
|
||||
tokenStore.put(relayState, token);
|
||||
request.setAttribute(SAML_REQUEST_TOKEN, relayState);
|
||||
response.addHeader(SAML_REQUEST_TOKEN, relayState);
|
||||
|
||||
log.debug("Saved SAMLRequest token with RelayState: {}", relayState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Saml2PostAuthenticationRequest loadAuthenticationRequest(HttpServletRequest request) {
|
||||
String token = extractTokenFromStore(request);
|
||||
|
||||
if (token == null) {
|
||||
log.debug("No SAMLResponse token found in RelayState");
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Object> claims = jwtService.extractClaims(token);
|
||||
return deserializeSamlRequest(claims);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Saml2PostAuthenticationRequest removeAuthenticationRequest(
|
||||
HttpServletRequest request, HttpServletResponse response) {
|
||||
Saml2PostAuthenticationRequest authRequest = loadAuthenticationRequest(request);
|
||||
|
||||
String relayStateId = request.getParameter("RelayState");
|
||||
if (relayStateId != null) {
|
||||
tokenStore.remove(relayStateId);
|
||||
log.debug("Removed SAMLRequest token for RelayState ID: {}", relayStateId);
|
||||
}
|
||||
|
||||
return authRequest;
|
||||
}
|
||||
|
||||
private String extractTokenFromStore(HttpServletRequest request) {
|
||||
String authnRequestId = request.getParameter("RelayState");
|
||||
|
||||
if (authnRequestId != null && !authnRequestId.isEmpty()) {
|
||||
String token = tokenStore.get(authnRequestId);
|
||||
|
||||
if (token != null) {
|
||||
tokenStore.remove(authnRequestId);
|
||||
log.debug("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId);
|
||||
return token;
|
||||
} else {
|
||||
log.warn("No SAMLRequest token found for RelayState ID: {}", authnRequestId);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<String, Object> serializeSamlRequest(Saml2PostAuthenticationRequest authRequest) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
|
||||
claims.put("id", authRequest.getId());
|
||||
claims.put("relyingPartyRegistrationId", authRequest.getRelyingPartyRegistrationId());
|
||||
claims.put("authenticationRequestUri", authRequest.getAuthenticationRequestUri());
|
||||
claims.put("samlRequest", authRequest.getSamlRequest());
|
||||
claims.put("relayState", authRequest.getRelayState());
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
private Saml2PostAuthenticationRequest deserializeSamlRequest(Map<String, Object> claims) {
|
||||
String relyingPartyRegistrationId = (String) claims.get("relyingPartyRegistrationId");
|
||||
RelyingPartyRegistration relyingPartyRegistration =
|
||||
relyingPartyRegistrationRepository.findByRegistrationId(relyingPartyRegistrationId);
|
||||
|
||||
if (relyingPartyRegistration == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Saml2PostAuthenticationRequest.withRelyingPartyRegistration(relyingPartyRegistration)
|
||||
.id((String) claims.get("id"))
|
||||
.authenticationRequestUri((String) claims.get("authenticationRequestUri"))
|
||||
.samlRequest((String) claims.get("samlRequest"))
|
||||
.relayState((String) claims.get("relayState"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+3
-39
@@ -3,7 +3,6 @@ package stirling.software.proprietary.security.saml2;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Collections;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.opensaml.saml.saml2.core.AuthnRequest;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
@@ -12,12 +11,10 @@ import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.security.saml2.core.Saml2X509Credential;
|
||||
import org.springframework.security.saml2.core.Saml2X509Credential.Saml2X509CredentialType;
|
||||
import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest;
|
||||
import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
|
||||
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
|
||||
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -27,7 +24,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
|
||||
@Configuration
|
||||
@Slf4j
|
||||
@@ -153,22 +149,10 @@ public class Saml2Configuration {
|
||||
return new InMemoryRelyingPartyRegistrationRepository(rp);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
|
||||
public Saml2AuthenticationRequestRepository<Saml2PostAuthenticationRequest>
|
||||
saml2AuthenticationRequestRepository(
|
||||
JwtServiceInterface jwtService,
|
||||
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) {
|
||||
return new JwtSaml2AuthenticationRequestRepository(
|
||||
new ConcurrentHashMap<>(), jwtService, relyingPartyRegistrationRepository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
|
||||
public OpenSaml4AuthenticationRequestResolver authenticationRequestResolver(
|
||||
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository,
|
||||
Saml2AuthenticationRequestRepository<Saml2PostAuthenticationRequest>
|
||||
saml2AuthenticationRequestRepository) {
|
||||
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) {
|
||||
OpenSaml4AuthenticationRequestResolver resolver =
|
||||
new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository);
|
||||
|
||||
@@ -176,30 +160,10 @@ public class Saml2Configuration {
|
||||
customizer -> {
|
||||
HttpServletRequest request = customizer.getRequest();
|
||||
AuthnRequest authnRequest = customizer.getAuthnRequest();
|
||||
Saml2PostAuthenticationRequest saml2AuthenticationRequest =
|
||||
saml2AuthenticationRequestRepository.loadAuthenticationRequest(request);
|
||||
|
||||
if (saml2AuthenticationRequest != null) {
|
||||
String sessionId = request.getSession(false).getId();
|
||||
// Generate a unique AuthnRequest ID for each SAML request
|
||||
authnRequest.setID("ARQ" + UUID.randomUUID().toString().substring(1));
|
||||
|
||||
log.debug(
|
||||
"Retrieving SAML 2 authentication request ID from the current HTTP session {}",
|
||||
sessionId);
|
||||
|
||||
String authenticationRequestId = saml2AuthenticationRequest.getId();
|
||||
|
||||
if (!authenticationRequestId.isBlank()) {
|
||||
authnRequest.setID(authenticationRequestId);
|
||||
} else {
|
||||
log.warn(
|
||||
"No authentication request found for HTTP session {}. Generating new ID",
|
||||
sessionId);
|
||||
authnRequest.setID("ARQ" + UUID.randomUUID().toString().substring(1));
|
||||
}
|
||||
} else {
|
||||
log.debug("Generating new authentication request ID");
|
||||
authnRequest.setID("ARQ" + UUID.randomUUID().toString().substring(1));
|
||||
}
|
||||
logAuthnRequestDetails(authnRequest);
|
||||
logHttpRequestDetails(request);
|
||||
});
|
||||
|
||||
+6
-2
@@ -21,6 +21,7 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
@@ -265,14 +266,17 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
String checksum = bytesToHex(digest.digest(content));
|
||||
log.info("Checksum for {}: {}", backupPath.getFileName(), checksum);
|
||||
|
||||
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:backupVerify");
|
||||
String verifyDbUrl = "jdbc:h2:mem:backupVerify_" + UUID.randomUUID();
|
||||
// Use a fresh in-memory database per verification to avoid leftover objects between
|
||||
// runs.
|
||||
try (Connection conn = DriverManager.getConnection(verifyDbUrl);
|
||||
PreparedStatement stmt = conn.prepareStatement("RUNSCRIPT FROM ?")) {
|
||||
stmt.setString(1, backupPath.toString());
|
||||
stmt.execute();
|
||||
}
|
||||
return true;
|
||||
} catch (IOException | NoSuchAlgorithmException | SQLException e) {
|
||||
log.error("Backup verification failed for {}: {}", backupPath, e.getMessage(), e);
|
||||
log.error("Backup verification failed for {}: {}", backupPath, e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/**
|
||||
* Service for managing multi-factor authentication (MFA) settings for users.
|
||||
*
|
||||
* <p>This service reads and writes MFA-related settings such as secrets, enablement flags, and
|
||||
* last-used TOTP steps.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MfaService {
|
||||
|
||||
public static final String MFA_ENABLED_KEY = "mfaEnabled";
|
||||
public static final String MFA_SECRET_KEY = "mfaSecret";
|
||||
public static final String MFA_LAST_USED_STEP_KEY = "mfaLastUsedStep";
|
||||
public static final String MFA_REQUIRED_KEY = "mfaRequired";
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final DatabaseServiceInterface databaseService;
|
||||
|
||||
/**
|
||||
* Determines whether MFA is enabled for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @return {@code true} if MFA is enabled
|
||||
*/
|
||||
public boolean isMfaEnabled(User user) {
|
||||
String value = getSetting(user, MFA_ENABLED_KEY);
|
||||
return Boolean.parseBoolean(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the MFA secret for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @return Base32-encoded secret, or {@code null} if not set
|
||||
*/
|
||||
public String getSecret(User user) {
|
||||
return getSetting(user, MFA_SECRET_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a new MFA secret and marks MFA as pending (disabled) for the user.
|
||||
*
|
||||
* @param user target user
|
||||
* @param secret Base32-encoded secret to store
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
@Transactional
|
||||
public void setSecret(User user, String secret)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "false");
|
||||
// Clear existing values and flush the removals before inserting a new secret. This keeps
|
||||
// the (user_id, setting_key) PK satisfied even when the persistence context re-inserts the
|
||||
// same keys within a single transaction.
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
if (managedUser != null && managedUser.getId() != null) {
|
||||
userRepository.deleteSettingsByUserIdAndKeys(
|
||||
managedUser.getId(), Arrays.asList(MFA_SECRET_KEY, MFA_LAST_USED_STEP_KEY));
|
||||
userRepository.flush();
|
||||
}
|
||||
settings.put(MFA_SECRET_KEY, secret);
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables MFA for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public void enableMfa(User user) throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "true");
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any pending MFA setup data for the user.
|
||||
*
|
||||
* @param user target user
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public void clearPendingSecret(User user) throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "false");
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables MFA and clears stored secrets for the user.
|
||||
*
|
||||
* @param user target user
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public void disableMfa(User user) throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "false");
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a TOTP time step has not been used before.
|
||||
*
|
||||
* @param user target user
|
||||
* @param timeStep candidate TOTP time step
|
||||
* @return {@code true} if the time step is usable
|
||||
*/
|
||||
public boolean isTotpStepUsable(User user, long timeStep) {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = managedUser.getSettings();
|
||||
if (settings == null) {
|
||||
return true;
|
||||
}
|
||||
String lastUsed = settings.get(MFA_LAST_USED_STEP_KEY);
|
||||
if (lastUsed == null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
long lastUsedStep = Long.parseLong(lastUsed);
|
||||
return timeStep > lastUsedStep;
|
||||
} catch (NumberFormatException ignored) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a TOTP time step as used, preventing replay.
|
||||
*
|
||||
* @param user target user
|
||||
* @param timeStep time step to mark as used
|
||||
* @return {@code true} if the time step was marked, {@code false} if it was already used
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public boolean markTotpStepUsed(User user, long timeStep)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
String lastUsed = settings.get(MFA_LAST_USED_STEP_KEY);
|
||||
if (lastUsed != null) {
|
||||
try {
|
||||
long lastUsedStep = Long.parseLong(lastUsed);
|
||||
if (timeStep <= lastUsedStep) {
|
||||
return false;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// treat malformed value as unused
|
||||
}
|
||||
}
|
||||
settings.put(MFA_LAST_USED_STEP_KEY, Long.toString(timeStep));
|
||||
persist(managedUser);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether MFA is required for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @return {@code true} if MFA is required
|
||||
*/
|
||||
public boolean isMfaRequired(User user) {
|
||||
String value = getSetting(user, MFA_REQUIRED_KEY);
|
||||
if (value == null) {
|
||||
value = "false";
|
||||
}
|
||||
log.info("MFA required for user {}: {}", user.getUsername(), value);
|
||||
return Boolean.parseBoolean(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether MFA is required for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @param required {@code true} to require MFA
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public void setMfaRequired(User user, boolean required)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_REQUIRED_KEY, Boolean.toString(required));
|
||||
log.info("Set MFA required={} for user {}", required, managedUser.getUsername());
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
private String getSetting(User user, String key) {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = managedUser.getSettings();
|
||||
if (settings == null) {
|
||||
return null;
|
||||
}
|
||||
return settings.get(key);
|
||||
}
|
||||
|
||||
private User getUserWithSettings(User user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
return user;
|
||||
}
|
||||
return userRepository.findByIdWithSettings(user.getId()).orElse(user);
|
||||
}
|
||||
|
||||
private Map<String, String> ensureSettings(User user) {
|
||||
Map<String, String> settings = user.getSettings();
|
||||
if (settings == null) {
|
||||
settings = new HashMap<>();
|
||||
user.setSettings(settings);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
private void persist(User user) throws SQLException, UnsupportedProviderException {
|
||||
userRepository.save(user);
|
||||
databaseService.exportDatabase();
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
|
||||
/**
|
||||
* Carries all attributes required to create or update a user account, including credentials,
|
||||
* SSO/provider details, team association, role and MFA configuration. Used by the security service
|
||||
* layer to persist or update users.
|
||||
*
|
||||
* <p>Defaults:
|
||||
*
|
||||
* <ul>
|
||||
* <li>password: null
|
||||
* <li>ssoProviderId: null
|
||||
* <li>ssoProvider: null
|
||||
* <li>authenticationType: {@code AuthenticationType.WEB}
|
||||
* <li>teamId: null
|
||||
* <li>team: null
|
||||
* <li>role: {@code Role.USER.getRoleId()}
|
||||
* <li>firstLogin: false
|
||||
* <li>enabled: true
|
||||
* <li>requireMfa: false
|
||||
* <li>mfaEnabled: false
|
||||
* <li>mfaSecret: null
|
||||
* <li>mfaLastUsedStep: null
|
||||
* </ul>
|
||||
*/
|
||||
@Getter
|
||||
@Builder(builderClassName = "Builder")
|
||||
public class SaveUserRequest {
|
||||
private final String username;
|
||||
@Builder.Default private final String password = null;
|
||||
@Builder.Default private final String ssoProviderId = null;
|
||||
@Builder.Default private final String ssoProvider = null;
|
||||
@Builder.Default private final AuthenticationType authenticationType = AuthenticationType.WEB;
|
||||
@Builder.Default private final Long teamId = null;
|
||||
@Builder.Default private final Team team = null;
|
||||
@Builder.Default private final String role = Role.USER.getRoleId();
|
||||
@Builder.Default private final boolean firstLogin = false;
|
||||
@Builder.Default private final boolean enabled = true;
|
||||
@Builder.Default private final boolean requireMfa = false;
|
||||
@Builder.Default private final boolean mfaEnabled = false;
|
||||
@Builder.Default private final String mfaSecret = null;
|
||||
@Builder.Default private final Long mfaLastUsedStep = null;
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.util.Base32Codec;
|
||||
|
||||
/**
|
||||
* Service for generating and validating TOTP secrets and codes for MFA authentication.
|
||||
*
|
||||
* <p>This service handles secret generation, code validation across time steps, and building
|
||||
* otpauth:// URIs to provision authenticator apps.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TotpService {
|
||||
|
||||
private static final int SECRET_LENGTH_BYTES = 20;
|
||||
private static final int CODE_DIGITS = 6;
|
||||
private static final int PERIOD_SECONDS = 30;
|
||||
private static final String HMAC_ALGORITHM = "HmacSHA1";
|
||||
private static final String DEFAULT_ISSUER = "Stirling PDF";
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Generates a new random TOTP secret encoded in Base32.
|
||||
*
|
||||
* @return Base32-encoded secret suitable for provisioning an authenticator app
|
||||
*/
|
||||
public String generateSecret() {
|
||||
byte[] secret = new byte[SECRET_LENGTH_BYTES];
|
||||
SECURE_RANDOM.nextBytes(secret);
|
||||
return Base32Codec.encode(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a submitted TOTP code is valid for the current time window.
|
||||
*
|
||||
* @param secret Base32-encoded shared secret
|
||||
* @param code six-digit code supplied by the user
|
||||
* @return {@code true} if the code is valid for the current time window
|
||||
*/
|
||||
public boolean isValidCode(String secret, String code) {
|
||||
return getValidTimeStep(secret, code) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a TOTP code and returns the time step it matches.
|
||||
*
|
||||
* @param secret Base32-encoded shared secret
|
||||
* @param code six-digit code supplied by the user
|
||||
* @return matching time step, or {@code null} if the code is invalid
|
||||
*/
|
||||
public Long getValidTimeStep(String secret, String code) {
|
||||
if (secret == null || secret.isBlank() || code == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedCode = code.replace(" ", "");
|
||||
if (!normalizedCode.matches("\\d{6}")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] secretKey;
|
||||
try {
|
||||
secretKey = Base32Codec.decode(secret);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
long timeStep = Instant.now().getEpochSecond() / PERIOD_SECONDS;
|
||||
byte[] normalizedCodeBytes = normalizedCode.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
for (int offset = -1; offset <= 1; offset++) {
|
||||
long candidate = timeStep + offset;
|
||||
String generatedCode = generateCode(secretKey, candidate);
|
||||
if (MessageDigest.isEqual(
|
||||
generatedCode.getBytes(StandardCharsets.UTF_8), normalizedCodeBytes)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an otpauth:// URI for configuring TOTP in authenticator apps.
|
||||
*
|
||||
* @param username account identifier to embed in the label
|
||||
* @param secret Base32-encoded secret to embed in the URI
|
||||
* @return otpauth URI that can be encoded as a QR code
|
||||
*/
|
||||
public String buildOtpAuthUri(String username, String secret) {
|
||||
String issuer = resolveIssuer();
|
||||
String label = encodeForOtpAuth(issuer + ":" + username);
|
||||
String encodedIssuer = encodeForOtpAuth(issuer);
|
||||
|
||||
return "otpauth://totp/"
|
||||
+ label
|
||||
+ "?secret="
|
||||
+ secret
|
||||
+ "&issuer="
|
||||
+ encodedIssuer
|
||||
+ "&algorithm=SHA1&digits="
|
||||
+ CODE_DIGITS
|
||||
+ "&period="
|
||||
+ PERIOD_SECONDS;
|
||||
}
|
||||
|
||||
private String encodeForOtpAuth(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
}
|
||||
|
||||
private String resolveIssuer() {
|
||||
if (applicationProperties.getUi() != null) {
|
||||
String appName = applicationProperties.getUi().getAppNameNavbar();
|
||||
if (appName != null && !appName.isBlank()) {
|
||||
return appName.trim();
|
||||
}
|
||||
}
|
||||
return DEFAULT_ISSUER;
|
||||
}
|
||||
|
||||
private String generateCode(byte[] secret, long timeStep) {
|
||||
try {
|
||||
ByteBuffer buffer = ByteBuffer.allocate(8);
|
||||
buffer.putLong(timeStep);
|
||||
|
||||
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(secret, HMAC_ALGORITHM));
|
||||
byte[] hash = mac.doFinal(buffer.array());
|
||||
|
||||
int offset = hash[hash.length - 1] & 0x0F;
|
||||
int binary =
|
||||
((hash[offset] & 0x7F) << 24)
|
||||
| ((hash[offset + 1] & 0xFF) << 16)
|
||||
| ((hash[offset + 2] & 0xFF) << 8)
|
||||
| (hash[offset + 3] & 0xFF);
|
||||
|
||||
int otp = binary % (int) Math.pow(10, CODE_DIGITS);
|
||||
return String.format("%0" + CODE_DIGITS + "d", otp);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to generate TOTP code", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
-200
@@ -1,5 +1,10 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static stirling.software.proprietary.security.service.MfaService.MFA_ENABLED_KEY;
|
||||
import static stirling.software.proprietary.security.service.MfaService.MFA_LAST_USED_STEP_KEY;
|
||||
import static stirling.software.proprietary.security.service.MfaService.MFA_REQUIRED_KEY;
|
||||
import static stirling.software.proprietary.security.service.MfaService.MFA_SECRET_KEY;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
@@ -62,6 +67,7 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
private final ApplicationProperties.Security.OAUTH2 oAuth2;
|
||||
|
||||
@Transactional
|
||||
public void processSSOPostLogin(
|
||||
String username,
|
||||
String ssoProviderId,
|
||||
@@ -101,7 +107,13 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
|
||||
if (autoCreateUser) {
|
||||
saveUser(username, ssoProviderId, ssoProvider, type);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(username)
|
||||
.ssoProviderId(ssoProviderId)
|
||||
.ssoProvider(ssoProvider)
|
||||
.authenticationType(type);
|
||||
saveUserCore(builder.build());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +153,14 @@ public class UserService implements UserServiceInterface {
|
||||
return user;
|
||||
}
|
||||
|
||||
private User saveUser(Optional<User> user, String apiKey) {
|
||||
if (user.isPresent()) {
|
||||
user.get().setApiKey(apiKey);
|
||||
return userRepository.save(user.get());
|
||||
}
|
||||
throw new UsernameNotFoundException("User not found");
|
||||
}
|
||||
|
||||
public User refreshApiKeyForUser(String username) {
|
||||
// reuse the add API key method for refreshing
|
||||
return addApiKeyToUser(username);
|
||||
@@ -179,162 +199,6 @@ public class UserService implements UserServiceInterface {
|
||||
return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey());
|
||||
}
|
||||
|
||||
public void saveUser(String username, AuthenticationType authenticationType)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
saveUser(username, authenticationType, (Long) null, Role.USER.getRoleId());
|
||||
}
|
||||
|
||||
public void saveUser(
|
||||
String username,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
AuthenticationType authenticationType)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
saveUser(
|
||||
username,
|
||||
ssoProviderId,
|
||||
ssoProvider,
|
||||
authenticationType,
|
||||
(Long) null,
|
||||
Role.USER.getRoleId());
|
||||
}
|
||||
|
||||
private User saveUser(Optional<User> user, String apiKey) {
|
||||
if (user.isPresent()) {
|
||||
user.get().setApiKey(apiKey);
|
||||
return userRepository.save(user.get());
|
||||
}
|
||||
throw new UsernameNotFoundException("User not found");
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username, AuthenticationType authenticationType, Long teamId, String role)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
null, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
authenticationType, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
role, // role
|
||||
false, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
AuthenticationType authenticationType,
|
||||
Long teamId,
|
||||
String role)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
null, // password
|
||||
ssoProviderId, // ssoProviderId
|
||||
ssoProvider, // ssoProvider
|
||||
authenticationType, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
role, // role
|
||||
false, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username, AuthenticationType authenticationType, Team team, String role)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
null, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
authenticationType, // authenticationType
|
||||
null, // teamId
|
||||
team, // team
|
||||
role, // role
|
||||
false, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(String username, String password, Long teamId)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
Role.USER.getRoleId(), // role
|
||||
false, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username, String password, Team team, String role, boolean firstLogin)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
null, // teamId
|
||||
team, // team
|
||||
role, // role
|
||||
firstLogin, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username, String password, Long teamId, String role, boolean firstLogin)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
role, // role
|
||||
firstLogin, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public void saveUser(String username, String password, Long teamId, String role)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
saveUser(username, password, teamId, role, false);
|
||||
}
|
||||
|
||||
public void saveUser(
|
||||
String username, String password, Long teamId, boolean firstLogin, boolean enabled)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
Role.USER.getRoleId(), // role
|
||||
firstLogin, // firstLogin
|
||||
enabled // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public void deleteUser(String username) {
|
||||
Optional<User> userOpt = findByUsernameIgnoreCase(username);
|
||||
if (userOpt.isPresent()) {
|
||||
@@ -485,75 +349,71 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* Core implementation for saving a user with all possible parameters. This method centralizes
|
||||
* the common logic for all saveUser variants.
|
||||
* Core method to save a user based on the provided SaveUserRequest.
|
||||
*
|
||||
* @param username Username for the new user
|
||||
* @param password Password for the user (may be null for SSO/OAuth users)
|
||||
* @param ssoProviderId Unique identifier from SSO provider (may be null for non-SSO users)
|
||||
* @param ssoProvider Name of the SSO provider (may be null for non-SSO users)
|
||||
* @param authenticationType Type of authentication (WEB, SSO, etc.)
|
||||
* @param teamId ID of the team to assign (may be null to use default)
|
||||
* @param team Team object to assign (takes precedence over teamId if both provided)
|
||||
* @param role Role to assign to the user
|
||||
* @param firstLogin Whether this is the user's first login
|
||||
* @param enabled Whether the user account is enabled
|
||||
* @param request The SaveUserRequest containing user details
|
||||
* @return The saved User object
|
||||
* @throws IllegalArgumentException If username is invalid or team is invalid
|
||||
* @throws SQLException If database operation fails
|
||||
* @throws UnsupportedProviderException If provider is not supported
|
||||
* @throws IllegalArgumentException If the username is invalid
|
||||
* @throws SQLException If a database error occurs
|
||||
* @throws UnsupportedProviderException If an unsupported provider is specified
|
||||
*/
|
||||
private User saveUserCore(
|
||||
String username,
|
||||
String password,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
AuthenticationType authenticationType,
|
||||
Long teamId,
|
||||
Team team,
|
||||
String role,
|
||||
boolean firstLogin,
|
||||
boolean enabled)
|
||||
public User saveUserCore(SaveUserRequest request)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
|
||||
if (!isUsernameValid(username)) {
|
||||
if (!isUsernameValid(request.getUsername())) {
|
||||
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(username);
|
||||
user.setUsername(request.getUsername());
|
||||
|
||||
// Set password if provided
|
||||
if (password != null && !password.isEmpty()) {
|
||||
user.setPassword(passwordEncoder.encode(password));
|
||||
if (request.getPassword() != null && !request.getPassword().isEmpty()) {
|
||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||
}
|
||||
|
||||
// Set SSO provider details if provided
|
||||
if (ssoProviderId != null && ssoProvider != null) {
|
||||
user.setSsoProviderId(ssoProviderId);
|
||||
user.setSsoProvider(ssoProvider);
|
||||
if (request.getSsoProviderId() != null && request.getSsoProvider() != null) {
|
||||
user.setSsoProviderId(request.getSsoProviderId());
|
||||
user.setSsoProvider(request.getSsoProvider());
|
||||
}
|
||||
|
||||
// Set authentication type
|
||||
user.setAuthenticationType(authenticationType);
|
||||
user.setAuthenticationType(request.getAuthenticationType());
|
||||
|
||||
// Set enabled status
|
||||
user.setEnabled(enabled);
|
||||
user.setEnabled(request.isEnabled());
|
||||
|
||||
// Set first login flag
|
||||
user.setFirstLogin(firstLogin);
|
||||
user.setFirstLogin(request.isFirstLogin());
|
||||
|
||||
// Set MFA requirement
|
||||
Map<String, String> settings = user.getSettings();
|
||||
settings.put(MFA_REQUIRED_KEY, String.valueOf(request.isRequireMfa()));
|
||||
settings.put(MFA_ENABLED_KEY, String.valueOf(request.isMfaEnabled()));
|
||||
if (request.getMfaSecret() != null && !request.getMfaSecret().isEmpty()) {
|
||||
settings.put(MFA_SECRET_KEY, request.getMfaSecret());
|
||||
} else {
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
}
|
||||
if (request.getMfaLastUsedStep() != null) {
|
||||
settings.put(MFA_LAST_USED_STEP_KEY, String.valueOf(request.getMfaLastUsedStep()));
|
||||
} else {
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
}
|
||||
log.info(
|
||||
"MFA required set to true for user {} {}",
|
||||
request.getUsername(),
|
||||
user.getSettings().toString());
|
||||
|
||||
// Set role (authority)
|
||||
if (role == null) {
|
||||
role = Role.USER.getRoleId();
|
||||
}
|
||||
user.addAuthority(new Authority(role, user));
|
||||
user.addAuthority(new Authority(request.getRole(), user));
|
||||
|
||||
// Resolve and set team
|
||||
if (team != null) {
|
||||
user.setTeam(team);
|
||||
if (request.getTeam() != null) {
|
||||
user.setTeam(request.getTeam());
|
||||
} else {
|
||||
user.setTeam(resolveTeam(teamId, this::getDefaultTeam));
|
||||
user.setTeam(resolveTeam(request.getTeamId(), this::getDefaultTeam));
|
||||
}
|
||||
|
||||
// Save user
|
||||
@@ -600,6 +460,32 @@ public class UserService implements UserServiceInterface {
|
||||
return user.isPresent() && user.get().hasPassword();
|
||||
}
|
||||
|
||||
public boolean isSsoAuthenticationTypeByUsername(String username) {
|
||||
Optional<User> user = findByUsernameIgnoreCase(username);
|
||||
if (user.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String authType = user.get().getAuthenticationType();
|
||||
if (authType == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
AuthenticationType authenticationType =
|
||||
AuthenticationType.valueOf(authType.toUpperCase(Locale.ROOT));
|
||||
if (authenticationType == AuthenticationType.OAUTH2
|
||||
|| authenticationType == AuthenticationType.SAML2) {
|
||||
return true;
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Fall through to legacy string comparison below
|
||||
}
|
||||
|
||||
// Backward compatibility for legacy "SSO" value without relying on the deprecated enum
|
||||
return "SSO".equalsIgnoreCase(authType);
|
||||
}
|
||||
|
||||
public boolean isAuthenticationTypeByUsername(
|
||||
String username, AuthenticationType authenticationType) {
|
||||
Optional<User> user = findByUsernameIgnoreCase(username);
|
||||
@@ -652,6 +538,7 @@ public class UserService implements UserServiceInterface {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCurrentUserAdmin() {
|
||||
try {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
@@ -667,6 +554,7 @@ public class UserService implements UserServiceInterface {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCurrentUserFirstLogin() {
|
||||
try {
|
||||
String username = getCurrentUsername();
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package stirling.software.proprietary.security.util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* RFC 4648 Base32 encoder/decoder for handling TOTP secrets.
|
||||
*
|
||||
* <p>This implementation is used to encode binary secrets into Base32 strings and decode them back
|
||||
* into byte arrays for TOTP processing.
|
||||
*/
|
||||
public final class Base32Codec {
|
||||
|
||||
private static final char[] ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray();
|
||||
private static final int[] LOOKUP_TABLE = new int[128];
|
||||
|
||||
static {
|
||||
Arrays.fill(LOOKUP_TABLE, -1);
|
||||
for (int i = 0; i < ALPHABET.length; i++) {
|
||||
LOOKUP_TABLE[ALPHABET[i]] = i;
|
||||
}
|
||||
}
|
||||
|
||||
private Base32Codec() {}
|
||||
|
||||
/**
|
||||
* Encodes the provided bytes into a Base32 string.
|
||||
*
|
||||
* @param data the raw bytes to encode
|
||||
* @return Base32-encoded representation of the input, or an empty string for null/empty input
|
||||
*/
|
||||
public static String encode(byte[] data) {
|
||||
if (data == null || data.length == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder result = new StringBuilder((data.length * 8 + 4) / 5);
|
||||
int buffer = data[0] & 0xFF;
|
||||
int bitsLeft = 8;
|
||||
int index = 1;
|
||||
|
||||
while (bitsLeft > 0 || index < data.length) {
|
||||
if (bitsLeft < 5) {
|
||||
if (index < data.length) {
|
||||
buffer = (buffer << 8) | (data[index++] & 0xFF);
|
||||
bitsLeft += 8;
|
||||
} else {
|
||||
int padding = 5 - bitsLeft;
|
||||
buffer <<= padding;
|
||||
bitsLeft += padding;
|
||||
}
|
||||
}
|
||||
|
||||
int digit = (buffer >> (bitsLeft - 5)) & 0x1F;
|
||||
bitsLeft -= 5;
|
||||
result.append(ALPHABET[digit]);
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a Base32 string into raw bytes.
|
||||
*
|
||||
* @param value Base32-encoded string (padding and whitespace are ignored)
|
||||
* @return decoded byte array, or an empty array for null/blank input
|
||||
* @throws IllegalArgumentException when the input contains invalid Base32 characters
|
||||
*/
|
||||
public static byte[] decode(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
String normalized = value.replace("=", "").replace(" ", "").toUpperCase(Locale.ROOT);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream(normalized.length());
|
||||
int buffer = 0;
|
||||
int bitsLeft = 0;
|
||||
|
||||
for (char character : normalized.toCharArray()) {
|
||||
if (character >= LOOKUP_TABLE.length || LOOKUP_TABLE[character] == -1) {
|
||||
throw new IllegalArgumentException("Invalid Base32 character: " + character);
|
||||
}
|
||||
|
||||
buffer = (buffer << 5) | LOOKUP_TABLE[character];
|
||||
bitsLeft += 5;
|
||||
|
||||
if (bitsLeft >= 8) {
|
||||
output.write((buffer >> (bitsLeft - 8)) & 0xFF);
|
||||
bitsLeft -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AccountData;
|
||||
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.DatabaseData;
|
||||
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.LoginData;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.database.repository.SessionRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ProprietaryUIDataControllerTest {
|
||||
|
||||
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
@Mock private SessionRepository sessionRepository;
|
||||
@Mock private DatabaseService databaseService;
|
||||
@Mock private UserLicenseSettingsService licenseSettingsService;
|
||||
@Mock private PersistentAuditEventRepository auditRepository;
|
||||
@Mock private MfaService mfaService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private AuditConfigurationProperties auditConfig;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private ProprietaryUIDataController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getUi().setLanguages(List.of("en", "de"));
|
||||
applicationProperties.getSystem().setDefaultLocale("en");
|
||||
applicationProperties.getSecurity().setEnableLogin(true);
|
||||
applicationProperties.getSecurity().getOauth2().setEnabled(false);
|
||||
applicationProperties.getSecurity().getSaml2().setEnabled(false);
|
||||
|
||||
auditConfig = new AuditConfigurationProperties(applicationProperties);
|
||||
objectMapper = new ObjectMapper();
|
||||
|
||||
controller =
|
||||
new ProprietaryUIDataController(
|
||||
applicationProperties,
|
||||
auditConfig,
|
||||
sessionPersistentRegistry,
|
||||
userRepository,
|
||||
teamRepository,
|
||||
sessionRepository,
|
||||
databaseService,
|
||||
objectMapper,
|
||||
false,
|
||||
licenseSettingsService,
|
||||
auditRepository,
|
||||
mfaService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginDataFlagsFirstTimeSetupWhenNoUsers() {
|
||||
when(userRepository.findAll()).thenReturn(Collections.emptyList());
|
||||
|
||||
ResponseEntity<LoginData> response = controller.getLoginData();
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
LoginData body = response.getBody();
|
||||
assertThat(body.isFirstTimeSetup()).isTrue();
|
||||
assertThat(body.isShowDefaultCredentials()).isTrue();
|
||||
assertThat(body.getLanguages()).containsExactly("en", "de");
|
||||
assertThat(body.getDefaultLocale()).isEqualTo("en");
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountDataReturnsUserSettingsAndMfaFlags() {
|
||||
User user = new User();
|
||||
user.setUsername("user@example.com");
|
||||
Authority authority = new Authority();
|
||||
authority.setAuthority(Role.USER.getRoleId());
|
||||
user.addAuthority(authority);
|
||||
user.setSettings(Map.of("theme", "dark"));
|
||||
|
||||
when(userRepository.findByUsernameIgnoreCaseWithSettings("user@example.com"))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
when(mfaService.isMfaRequired(user)).thenReturn(false);
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
|
||||
|
||||
ResponseEntity<AccountData> response = controller.getAccountData(authentication);
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
AccountData data = response.getBody();
|
||||
assertThat(data.getUsername()).isEqualTo("user@example.com");
|
||||
assertThat(data.isMfaEnabled()).isTrue();
|
||||
assertThat(data.isMfaRequired()).isFalse();
|
||||
assertThat(data.getSettings()).contains("\"theme\":\"dark\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void databaseDataMarksUnknownVersion() {
|
||||
when(databaseService.getBackupList()).thenReturn(List.of());
|
||||
when(databaseService.getH2Version()).thenReturn("Unknown");
|
||||
|
||||
ResponseEntity<DatabaseData> response = controller.getDatabaseData();
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
DatabaseData data = response.getBody();
|
||||
assertThat(data.getBackupFiles()).isEmpty();
|
||||
assertThat(data.isVersionUnknown()).isTrue();
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InitialSecuritySetupTest {
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private TeamService teamService;
|
||||
@Mock private DatabaseServiceInterface databaseService;
|
||||
@Mock private UserLicenseSettingsService licenseSettingsService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private InitialSecuritySetup initialSecuritySetup;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getSecurity().getInitialLogin().setUsername("admin");
|
||||
applicationProperties.getSecurity().getInitialLogin().setPassword("password");
|
||||
Team internalTeam = new Team();
|
||||
internalTeam.setName(TeamService.INTERNAL_TEAM_NAME);
|
||||
User internalUser = new User();
|
||||
internalUser.setUsername(Role.INTERNAL_API_USER.getRoleId());
|
||||
internalUser.setTeam(internalTeam);
|
||||
when(userService.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
|
||||
.thenReturn(Optional.of(internalUser));
|
||||
when(teamService.getOrCreateInternalTeam()).thenReturn(internalTeam);
|
||||
initialSecuritySetup =
|
||||
new InitialSecuritySetup(
|
||||
userService,
|
||||
teamService,
|
||||
applicationProperties,
|
||||
databaseService,
|
||||
licenseSettingsService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void initImportsBackupWhenPresent() throws SQLException, UnsupportedProviderException {
|
||||
when(userService.hasUsers()).thenReturn(false);
|
||||
when(databaseService.hasBackup()).thenReturn(true);
|
||||
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
|
||||
when(userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
|
||||
.thenReturn(true);
|
||||
|
||||
initialSecuritySetup.init();
|
||||
|
||||
verify(databaseService).importDatabase();
|
||||
verify(userService, never()).saveUserCore(any());
|
||||
verify(licenseSettingsService).initializeGrandfatheredCount();
|
||||
verify(licenseSettingsService).updateLicenseMaxUsers();
|
||||
}
|
||||
|
||||
@Test
|
||||
void initCreatesConfiguredAdminWhenNoBackup()
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
when(userService.hasUsers()).thenReturn(false);
|
||||
when(databaseService.hasBackup()).thenReturn(false);
|
||||
when(userService.findByUsernameIgnoreCase("admin")).thenReturn(Optional.empty());
|
||||
Team defaultTeam = new Team();
|
||||
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
|
||||
when(teamService.getOrCreateDefaultTeam()).thenReturn(defaultTeam);
|
||||
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
|
||||
when(userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
|
||||
.thenReturn(true);
|
||||
|
||||
initialSecuritySetup.init();
|
||||
|
||||
ArgumentCaptor<SaveUserRequest> captor = ArgumentCaptor.forClass(SaveUserRequest.class);
|
||||
verify(userService).saveUserCore(captor.capture());
|
||||
SaveUserRequest saved = captor.getValue();
|
||||
assertThat(saved.getUsername()).isEqualTo("admin");
|
||||
assertThat(saved.getPassword()).isEqualTo("password");
|
||||
}
|
||||
|
||||
@Test
|
||||
void configureJwtSettingsDisablesKeyCleanupWhenJwtDisabled() {
|
||||
ReflectionTestUtils.setField(initialSecuritySetup, "v2Enabled", true);
|
||||
applicationProperties.getSecurity().getJwt().setEnableKeystore(false);
|
||||
when(userService.hasUsers()).thenReturn(true);
|
||||
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
|
||||
when(userService.usernameExistsIgnoreCase(any())).thenReturn(true);
|
||||
|
||||
initialSecuritySetup.init();
|
||||
|
||||
assertThat(applicationProperties.getSecurity().getJwt().isEnableKeyCleanup()).isFalse();
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.TotpService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthControllerLoginTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private ApplicationProperties.Security securityProperties;
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private JwtServiceInterface jwtService;
|
||||
@Mock private CustomUserDetailsService userDetailsService;
|
||||
@Mock private LoginAttemptService loginAttemptService;
|
||||
@Mock private MfaService mfaService;
|
||||
@Mock private TotpService totpService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
securityProperties = new ApplicationProperties.Security();
|
||||
securityProperties.setLoginMethod("all");
|
||||
|
||||
AuthController controller =
|
||||
new AuthController(
|
||||
userService,
|
||||
jwtService,
|
||||
userDetailsService,
|
||||
loginAttemptService,
|
||||
mfaService,
|
||||
totpService,
|
||||
securityProperties);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginRejectsWhenUserPassDisabled() throws Exception {
|
||||
securityProperties.setLoginMethod(
|
||||
ApplicationProperties.Security.LoginMethods.OAUTH2.toString());
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(
|
||||
jsonPath("$.error")
|
||||
.value(
|
||||
"Username/password authentication is not enabled. Please use the configured authentication method."));
|
||||
|
||||
verify(userDetailsService, never()).loadUserByUsername(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginBlockedAccountReturnsUnauthorized() throws Exception {
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
when(loginAttemptService.isBlocked("user@example.com")).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(
|
||||
jsonPath("$.error")
|
||||
.value("Account is locked due to too many failed attempts"));
|
||||
|
||||
verify(loginAttemptService, never()).loginSucceeded(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginRequiresMfaCodeWhenEnabled() throws Exception {
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
User user = buildUser();
|
||||
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(userService.isPasswordCorrect(user, "pw")).thenReturn(true);
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("mfa_required"));
|
||||
|
||||
verify(loginAttemptService, never()).loginSucceeded(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginFailsWhenPasswordIncorrect() throws Exception {
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
User user = buildUser();
|
||||
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(userService.isPasswordCorrect(user, "pw")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("Invalid credentials"));
|
||||
|
||||
verify(loginAttemptService).loginFailed("user@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginSucceedsAndGeneratesToken() throws Exception {
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
User user = buildUser();
|
||||
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(userService.isPasswordCorrect(user, "pw")).thenReturn(true);
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(false);
|
||||
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
|
||||
.thenReturn("token-123");
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.session.access_token").value("token-123"))
|
||||
.andExpect(jsonPath("$.user.username").value("user@example.com"));
|
||||
|
||||
verify(loginAttemptService).loginSucceeded("user@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshReturnsUnauthorizedWhenTokenMissing() throws Exception {
|
||||
when(jwtService.extractToken(any())).thenReturn(null);
|
||||
mockMvc.perform(post("/api/v1/auth/refresh"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("No token found"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshReturnsNewTokenWhenValid() throws Exception {
|
||||
User user = buildUser();
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
when(jwtService.extractUsername("old")).thenReturn("user@example.com");
|
||||
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
|
||||
.thenReturn("new-token");
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/refresh"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.user").exists())
|
||||
.andExpect(jsonPath("$.session.access_token").value("new-token"))
|
||||
.andExpect(jsonPath("$.session.expires_in").value(3600));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCurrentUserReturnsUnauthorizedWhenAnonymous() throws Exception {
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/me"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("Not authenticated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCurrentUserReturnsUserDetails() throws Exception {
|
||||
User user = buildUser();
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/me"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.user.username").value("user@example.com"))
|
||||
.andExpect(
|
||||
jsonPath("$.user.authenticationType")
|
||||
.value(AuthenticationType.WEB.name().toLowerCase()));
|
||||
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private User buildUser() {
|
||||
User user = new User();
|
||||
user.setUsername("user@example.com");
|
||||
user.setEnabled(true);
|
||||
user.setAuthenticationType(AuthenticationType.WEB);
|
||||
|
||||
Authority authority = new Authority();
|
||||
authority.setAuthority(Role.USER.getRoleId());
|
||||
user.addAuthorities(Set.of(authority));
|
||||
return user;
|
||||
}
|
||||
|
||||
private UsernameAndPassMfa buildPayload(String mfaCode) {
|
||||
UsernameAndPassMfa payload = new UsernameAndPassMfa();
|
||||
payload.setUsername("user@example.com");
|
||||
payload.setPassword("pw");
|
||||
payload.setMfaCode(mfaCode);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.TotpService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthControllerMfaTest {
|
||||
|
||||
private static final String USERNAME = "user@example.com";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private Authentication authentication;
|
||||
private User user;
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private JwtServiceInterface jwtService;
|
||||
@Mock private CustomUserDetailsService userDetailsService;
|
||||
@Mock private LoginAttemptService loginAttemptService;
|
||||
@Mock private MfaService mfaService;
|
||||
@Mock private TotpService totpService;
|
||||
|
||||
@InjectMocks private AuthController authController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(authController).build();
|
||||
authentication = new UsernamePasswordAuthenticationToken(USERNAME, "password", List.of());
|
||||
user = new User();
|
||||
user.setUsername(USERNAME);
|
||||
user.setAuthenticationType(AuthenticationType.WEB);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupMfaRequiresAuthentication() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/auth/mfa/setup"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(content().json("{\"error\":\"Not authenticated\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupMfaReturnsSecretAndUri() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(false);
|
||||
when(totpService.generateSecret()).thenReturn("SECRET");
|
||||
when(totpService.buildOtpAuthUri(USERNAME, "SECRET")).thenReturn("otpauth://test");
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.secret").value("SECRET"))
|
||||
.andExpect(jsonPath("$.otpauthUri").value("otpauth://test"));
|
||||
|
||||
verify(mfaService).setSecret(user, "SECRET");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupMfaReturnsConflictWhenAlreadyEnabled() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(content().json("{\"error\":\"MFA already enabled\"}"));
|
||||
|
||||
verify(totpService, never()).generateSecret();
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupMfaRejectsNonWebAuthenticationType() throws Exception {
|
||||
user.setAuthenticationType(AuthenticationType.OAUTH2);
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(
|
||||
content()
|
||||
.json(
|
||||
"{\"error\":\"MFA settings are only available for web accounts\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void enableMfaRejectsMissingCode() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.getSecret(user)).thenReturn("SECRET");
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/mfa/enable")
|
||||
.principal(authentication)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().json("{\"error\":\"MFA code is required\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void enableMfaCompletesWorkflow() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.getSecret(user)).thenReturn("SECRET");
|
||||
when(totpService.getValidTimeStep("SECRET", "123456")).thenReturn(42L);
|
||||
when(mfaService.isTotpStepUsable(user, 42L)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/mfa/enable")
|
||||
.principal(authentication)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("code", "123456"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.enabled").value(true));
|
||||
|
||||
verify(mfaService).enableMfa(user);
|
||||
verify(mfaService).markTotpStepUsed(user, 42L);
|
||||
verify(mfaService).setMfaRequired(user, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableMfaCompletesWorkflow() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
when(mfaService.getSecret(user)).thenReturn("SECRET");
|
||||
when(totpService.getValidTimeStep("SECRET", "654321")).thenReturn(7L);
|
||||
when(mfaService.isTotpStepUsable(user, 7L)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/mfa/disable")
|
||||
.principal(authentication)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("code", "654321"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.enabled").value(false));
|
||||
|
||||
verify(mfaService).disableMfa(user);
|
||||
verify(mfaService).markTotpStepUsed(user, 7L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableMfaReturnsDisabledWhenNotEnabled() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(false);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/mfa/disable")
|
||||
.principal(authentication)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("code", "654321"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.enabled").value(false));
|
||||
|
||||
verify(mfaService, never()).getSecret(user);
|
||||
verifyNoInteractions(totpService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelMfaSetupClearsPendingSecret() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/mfa/setup/cancel").principal(authentication))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.cleared").value(true));
|
||||
|
||||
verify(mfaService).clearPendingSecret(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelMfaSetupReturnsConflictWhenEnabled() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/mfa/setup/cancel").principal(authentication))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(content().json("{\"error\":\"MFA already enabled\"}"));
|
||||
|
||||
verify(mfaService, never()).clearPendingSecret(user);
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.hamcrest.Matchers.startsWith;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.InviteToken;
|
||||
import stirling.software.proprietary.security.repository.InviteTokenRepository;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InviteLinkControllerTest {
|
||||
|
||||
@Mock private InviteTokenRepository inviteTokenRepository;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
@Mock private UserService userService;
|
||||
@Mock private EmailService emailService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private MockMvc mockMvc;
|
||||
private Principal adminPrincipal;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getMail().setEnableInvites(true);
|
||||
applicationProperties.getMail().setInviteLinkExpiryHours(24);
|
||||
applicationProperties.getSystem().setFrontendUrl("https://frontend.example.com");
|
||||
|
||||
adminPrincipal = () -> "admin";
|
||||
|
||||
InviteLinkController controller =
|
||||
new InviteLinkController(
|
||||
inviteTokenRepository,
|
||||
teamRepository,
|
||||
userService,
|
||||
applicationProperties,
|
||||
Optional.of(emailService));
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateInviteLinkRejectsWhenInvitesDisabled() throws Exception {
|
||||
applicationProperties.getMail().setEnableInvites(false);
|
||||
|
||||
mockMvc.perform(post("/api/v1/invite/generate").principal(adminPrincipal))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("Email invites are not enabled"));
|
||||
|
||||
verify(inviteTokenRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateInviteLinkRejectsInvalidEmail() throws Exception {
|
||||
applicationProperties.getMail().setEnableInvites(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/invite/generate")
|
||||
.principal(adminPrincipal)
|
||||
.param("email", "not-an-email"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("Invalid email address"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateInviteLinkBlocksOnLicenseLimit() throws Exception {
|
||||
applicationProperties.getPremium().setEnabled(true);
|
||||
applicationProperties.getPremium().setMaxUsers(1);
|
||||
when(userService.getTotalUsersCount()).thenReturn(1L);
|
||||
when(inviteTokenRepository.countActiveInvites(any(LocalDateTime.class))).thenReturn(0L);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/invite/generate")
|
||||
.principal(adminPrincipal)
|
||||
.param("email", "new@ex.com"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value(startsWith("License limit reached")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateInviteLinkBuildsFrontendUrl() throws Exception {
|
||||
Team defaultTeam = new Team();
|
||||
defaultTeam.setId(5L);
|
||||
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
|
||||
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
|
||||
.thenReturn(Optional.of(defaultTeam));
|
||||
when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false);
|
||||
when(inviteTokenRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/invite/generate")
|
||||
.principal(adminPrincipal)
|
||||
.param("email", "new@example.com"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(
|
||||
jsonPath("$.inviteUrl")
|
||||
.value(startsWith("https://frontend.example.com/invite?token=")))
|
||||
.andExpect(jsonPath("$.email").value("new@example.com"));
|
||||
|
||||
verify(inviteTokenRepository).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateInviteTokenReturnsGoneWhenExpired() throws Exception {
|
||||
InviteToken expired = new InviteToken();
|
||||
expired.setToken("abc");
|
||||
expired.setExpiresAt(LocalDateTime.now().minusHours(1));
|
||||
expired.setRole(Role.USER.getRoleId());
|
||||
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(expired));
|
||||
|
||||
mockMvc.perform(get("/api/v1/invite/validate/abc"))
|
||||
.andExpect(status().isGone())
|
||||
.andExpect(jsonPath("$.error").value("This invite link has expired"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteCreatesUserWhenEmailProvided() throws Exception {
|
||||
InviteToken invite = new InviteToken();
|
||||
invite.setToken("abc");
|
||||
invite.setExpiresAt(LocalDateTime.now().plusHours(2));
|
||||
invite.setRole(Role.USER.getRoleId());
|
||||
invite.setUsed(false);
|
||||
invite.setEmail(null); // email required from request
|
||||
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(invite));
|
||||
when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/invite/accept/abc")
|
||||
.param("email", "new@example.com")
|
||||
.param("password", "password123"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Account created successfully"))
|
||||
.andExpect(jsonPath("$.username").value("new@example.com"));
|
||||
|
||||
verify(userService).saveUserCore(any());
|
||||
verify(inviteTokenRepository).save(invite);
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserControllerTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private SessionPersistentRegistry sessionRegistry;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private EmailService emailService;
|
||||
@Mock private UserLicenseSettingsService licenseSettingsService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getPremium().setMaxUsers(10);
|
||||
applicationProperties.getMail().setEnabled(true);
|
||||
|
||||
UserController controller =
|
||||
new UserController(
|
||||
userService,
|
||||
sessionRegistry,
|
||||
applicationProperties,
|
||||
teamRepository,
|
||||
userRepository,
|
||||
Optional.of(emailService),
|
||||
licenseSettingsService);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerRejectsExistingUser() throws Exception {
|
||||
UsernameAndPass payload = new UsernameAndPass();
|
||||
payload.setUsername("existing@example.com");
|
||||
payload.setPassword("pw");
|
||||
when(userService.usernameExistsIgnoreCase("existing@example.com")).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/user/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("User already exists"));
|
||||
|
||||
verify(userService, never()).saveUserCore(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerCreatesUserWhenValid() throws Exception {
|
||||
UsernameAndPass payload = new UsernameAndPass();
|
||||
payload.setUsername("new@example.com");
|
||||
payload.setPassword("pw");
|
||||
Team defaultTeam = new Team();
|
||||
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
|
||||
|
||||
when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false);
|
||||
when(userService.isUsernameValid("new@example.com")).thenReturn(true);
|
||||
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
|
||||
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
|
||||
.thenReturn(Optional.of(defaultTeam));
|
||||
|
||||
User savedUser = new User();
|
||||
savedUser.setUsername("new@example.com");
|
||||
savedUser.setEnabled(false);
|
||||
when(userService.saveUserCore(any())).thenReturn(savedUser);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/user/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.user.username").value("new@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changeUserEnabledPreventsSelfDisable() throws Exception {
|
||||
User user = new User();
|
||||
user.setUsername("admin");
|
||||
when(userService.usernameExistsIgnoreCase("admin")).thenReturn(true);
|
||||
when(userService.findByUsernameIgnoreCase("admin")).thenReturn(Optional.of(user));
|
||||
Authentication authentication = new UsernamePasswordAuthenticationToken("admin", "pw");
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/user/admin/changeUserEnabled/admin")
|
||||
.param("enabled", "false")
|
||||
.principal(authentication))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("Cannot disable your own account."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changePasswordRejectsMissingUser() throws Exception {
|
||||
Authentication authentication = new UsernamePasswordAuthenticationToken("ghost", "pw");
|
||||
when(userService.usernameExistsIgnoreCase("ghost")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(post("/api/v1/user/admin/deleteUser/ghost").principal(authentication))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.error").value("User not found."));
|
||||
}
|
||||
}
|
||||
+16
-16
@@ -36,12 +36,12 @@ class H2SQLConditionTest {
|
||||
|
||||
@Test
|
||||
void returnsFalse_whenEnableCustomDatabase_true_regardlessOfType() {
|
||||
// Flag=true, Typ=h2 -> false
|
||||
// Flag=true, Typ=h2 -> true
|
||||
MockEnvironment envTrueH2 =
|
||||
new MockEnvironment()
|
||||
.withProperty("system.datasource.enableCustomDatabase", "true")
|
||||
.withProperty("system.datasource.type", "h2");
|
||||
assertFalse(eval(envTrueH2));
|
||||
assertTrue(eval(envTrueH2));
|
||||
|
||||
// Flag=true, Typ=postgres -> false
|
||||
MockEnvironment envTrueOther =
|
||||
@@ -57,20 +57,6 @@ class H2SQLConditionTest {
|
||||
assertFalse(eval(envTrueMissingType));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsFalse_whenTypeNotH2_orMissing_andFlagNotEnabled() {
|
||||
// Flag fehlt, Typ=postgres -> false
|
||||
MockEnvironment envNotH2 =
|
||||
new MockEnvironment().withProperty("system.datasource.type", "postgresql");
|
||||
assertFalse(eval(envNotH2));
|
||||
|
||||
// Flag=false, Typ fehlt -> false (Default: "")
|
||||
MockEnvironment envMissingType =
|
||||
new MockEnvironment()
|
||||
.withProperty("system.datasource.enableCustomDatabase", "false");
|
||||
assertFalse(eval(envMissingType));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsFalse_whenEnabled_but_type_not_h2_or_missing() {
|
||||
MockEnvironment envNotH2 =
|
||||
@@ -84,4 +70,18 @@ class H2SQLConditionTest {
|
||||
.withProperty("system.datasource.enableCustomDatabase", "true");
|
||||
assertFalse(eval(envMissingType));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsTrue_whenTypeNotH2_andCustomDatabaseDisabled() {
|
||||
MockEnvironment envNotH2 =
|
||||
new MockEnvironment()
|
||||
.withProperty("system.datasource.enableCustomDatabase", "false")
|
||||
.withProperty("system.datasource.type", "postgresql");
|
||||
assertTrue(eval(envNotH2));
|
||||
|
||||
MockEnvironment envMissingType =
|
||||
new MockEnvironment()
|
||||
.withProperty("system.datasource.enableCustomDatabase", "false");
|
||||
assertTrue(eval(envMissingType));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -113,10 +113,10 @@ class UserTest {
|
||||
|
||||
// Use an existing value from your AuthenticationType enum (e.g. OAUTH2/SAML2/DATABASE)
|
||||
// If the name differs, simply adjust below.
|
||||
AuthenticationType at = AuthenticationType.SSO;
|
||||
AuthenticationType at = AuthenticationType.OAUTH2;
|
||||
u.setAuthenticationType(at);
|
||||
|
||||
assertEquals("sso", u.getAuthenticationType());
|
||||
assertEquals("oauth2", u.getAuthenticationType());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
-243
@@ -1,243 +0,0 @@
|
||||
package stirling.software.proprietary.security.saml2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest;
|
||||
import org.springframework.security.saml2.provider.service.registration.AssertingPartyMetadata;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JwtSaml2AuthenticationRequestRepositoryTest {
|
||||
|
||||
private static final String SAML_REQUEST_TOKEN = "stirling_saml_request_token";
|
||||
|
||||
private Map<String, String> tokenStore;
|
||||
|
||||
@Mock private JwtServiceInterface jwtService;
|
||||
|
||||
@Mock private RelyingPartyRegistrationRepository relyingPartyRegistrationRepository;
|
||||
|
||||
private JwtSaml2AuthenticationRequestRepository jwtSaml2AuthenticationRequestRepository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tokenStore = new ConcurrentHashMap<>();
|
||||
jwtSaml2AuthenticationRequestRepository =
|
||||
new JwtSaml2AuthenticationRequestRepository(
|
||||
tokenStore, jwtService, relyingPartyRegistrationRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAuthenticationRequest() {
|
||||
var authRequest = mock(Saml2PostAuthenticationRequest.class);
|
||||
var request = mock(MockHttpServletRequest.class);
|
||||
var response = mock(MockHttpServletResponse.class);
|
||||
String token = "testToken";
|
||||
String id = "testId";
|
||||
String relayState = "testRelayState";
|
||||
String authnRequestUri = "example.com/authnRequest";
|
||||
String samlRequest = "testSamlRequest";
|
||||
String relyingPartyRegistrationId = "stirling-pdf";
|
||||
|
||||
when(jwtService.isJwtEnabled()).thenReturn(true);
|
||||
when(authRequest.getRelayState()).thenReturn(relayState);
|
||||
when(authRequest.getId()).thenReturn(id);
|
||||
when(authRequest.getAuthenticationRequestUri()).thenReturn(authnRequestUri);
|
||||
when(authRequest.getSamlRequest()).thenReturn(samlRequest);
|
||||
when(authRequest.getRelyingPartyRegistrationId()).thenReturn(relyingPartyRegistrationId);
|
||||
when(jwtService.generateToken(eq(""), anyMap())).thenReturn(token);
|
||||
|
||||
jwtSaml2AuthenticationRequestRepository.saveAuthenticationRequest(
|
||||
authRequest, request, response);
|
||||
|
||||
verify(request).setAttribute(SAML_REQUEST_TOKEN, relayState);
|
||||
verify(response).addHeader(SAML_REQUEST_TOKEN, relayState);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAuthenticationRequestWithNullRequest() {
|
||||
var request = mock(MockHttpServletRequest.class);
|
||||
var response = mock(MockHttpServletResponse.class);
|
||||
|
||||
jwtSaml2AuthenticationRequestRepository.saveAuthenticationRequest(null, request, response);
|
||||
|
||||
assertTrue(tokenStore.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadAuthenticationRequest() {
|
||||
var request = mock(MockHttpServletRequest.class);
|
||||
var relyingPartyRegistration = mock(RelyingPartyRegistration.class);
|
||||
var assertingPartyMetadata = mock(AssertingPartyMetadata.class);
|
||||
String relayState = "testRelayState";
|
||||
String token = "testToken";
|
||||
Map<String, Object> claims =
|
||||
Map.of(
|
||||
"id", "testId",
|
||||
"relyingPartyRegistrationId", "stirling-pdf",
|
||||
"authenticationRequestUri", "example.com/authnRequest",
|
||||
"samlRequest", "testSamlRequest",
|
||||
"relayState", relayState);
|
||||
|
||||
when(request.getParameter("RelayState")).thenReturn(relayState);
|
||||
when(jwtService.extractClaims(token)).thenReturn(claims);
|
||||
when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf"))
|
||||
.thenReturn(relyingPartyRegistration);
|
||||
when(relyingPartyRegistration.getRegistrationId()).thenReturn("stirling-pdf");
|
||||
when(relyingPartyRegistration.getAssertingPartyMetadata())
|
||||
.thenReturn(assertingPartyMetadata);
|
||||
when(assertingPartyMetadata.getSingleSignOnServiceLocation())
|
||||
.thenReturn("https://example.com/sso");
|
||||
tokenStore.put(relayState, token);
|
||||
|
||||
var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request);
|
||||
|
||||
assertNotNull(result);
|
||||
assertFalse(tokenStore.containsKey(relayState));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
void loadAuthenticationRequestWithInvalidRelayState(String relayState) {
|
||||
var request = mock(MockHttpServletRequest.class);
|
||||
when(request.getParameter("RelayState")).thenReturn(relayState);
|
||||
|
||||
var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadAuthenticationRequestWithNonExistentToken() {
|
||||
var request = mock(MockHttpServletRequest.class);
|
||||
when(request.getParameter("RelayState")).thenReturn("nonExistentRelayState");
|
||||
|
||||
var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadAuthenticationRequestWithNullRelyingPartyRegistration() {
|
||||
var request = mock(MockHttpServletRequest.class);
|
||||
String relayState = "testRelayState";
|
||||
String token = "testToken";
|
||||
Map<String, Object> claims =
|
||||
Map.of(
|
||||
"id", "testId",
|
||||
"relyingPartyRegistrationId", "stirling-pdf",
|
||||
"authenticationRequestUri", "example.com/authnRequest",
|
||||
"samlRequest", "testSamlRequest",
|
||||
"relayState", relayState);
|
||||
|
||||
when(request.getParameter("RelayState")).thenReturn(relayState);
|
||||
when(jwtService.extractClaims(token)).thenReturn(claims);
|
||||
when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf"))
|
||||
.thenReturn(null);
|
||||
tokenStore.put(relayState, token);
|
||||
|
||||
var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeAuthenticationRequest() {
|
||||
var request = mock(HttpServletRequest.class);
|
||||
var response = mock(HttpServletResponse.class);
|
||||
var relyingPartyRegistration = mock(RelyingPartyRegistration.class);
|
||||
var assertingPartyMetadata = mock(AssertingPartyMetadata.class);
|
||||
String relayState = "testRelayState";
|
||||
String token = "testToken";
|
||||
Map<String, Object> claims =
|
||||
Map.of(
|
||||
"id", "testId",
|
||||
"relyingPartyRegistrationId", "stirling-pdf",
|
||||
"authenticationRequestUri", "example.com/authnRequest",
|
||||
"samlRequest", "testSamlRequest",
|
||||
"relayState", relayState);
|
||||
|
||||
when(request.getParameter("RelayState")).thenReturn(relayState);
|
||||
when(jwtService.extractClaims(token)).thenReturn(claims);
|
||||
when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf"))
|
||||
.thenReturn(relyingPartyRegistration);
|
||||
when(relyingPartyRegistration.getRegistrationId()).thenReturn("stirling-pdf");
|
||||
when(relyingPartyRegistration.getAssertingPartyMetadata())
|
||||
.thenReturn(assertingPartyMetadata);
|
||||
when(assertingPartyMetadata.getSingleSignOnServiceLocation())
|
||||
.thenReturn("https://example.com/sso");
|
||||
tokenStore.put(relayState, token);
|
||||
|
||||
var result =
|
||||
jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest(
|
||||
request, response);
|
||||
|
||||
assertNotNull(result);
|
||||
assertFalse(tokenStore.containsKey(relayState));
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeAuthenticationRequestWithNullRelayState() {
|
||||
var request = mock(HttpServletRequest.class);
|
||||
var response = mock(HttpServletResponse.class);
|
||||
when(request.getParameter("RelayState")).thenReturn(null);
|
||||
|
||||
var result =
|
||||
jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest(
|
||||
request, response);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeAuthenticationRequestWithNonExistentToken() {
|
||||
var request = mock(HttpServletRequest.class);
|
||||
var response = mock(HttpServletResponse.class);
|
||||
when(request.getParameter("RelayState")).thenReturn("nonExistentRelayState");
|
||||
|
||||
var result =
|
||||
jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest(
|
||||
request, response);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeAuthenticationRequestWithOnlyRelayState() {
|
||||
var request = mock(HttpServletRequest.class);
|
||||
var response = mock(HttpServletResponse.class);
|
||||
String relayState = "testRelayState";
|
||||
|
||||
when(request.getParameter("RelayState")).thenReturn(relayState);
|
||||
|
||||
var result =
|
||||
jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest(
|
||||
request, response);
|
||||
|
||||
assertNull(result);
|
||||
assertFalse(tokenStore.containsKey(relayState));
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.FileInfo;
|
||||
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
|
||||
|
||||
class DatabaseServiceTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private DatabaseNotificationServiceInterface notificationService;
|
||||
|
||||
private DatabaseService databaseService;
|
||||
private ApplicationProperties.Datasource datasourceProps;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
datasourceProps = new ApplicationProperties.Datasource();
|
||||
datasourceProps.setType(ApplicationProperties.Driver.H2.name());
|
||||
datasourceProps.setCustomDatabaseUrl("jdbc:h2:mem:test");
|
||||
datasourceProps.setEnableCustomDatabase(false);
|
||||
|
||||
DataSource dataSource =
|
||||
new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
|
||||
|
||||
databaseService = new DatabaseService(datasourceProps, dataSource, notificationService);
|
||||
ReflectionTestUtils.setField(databaseService, "BACKUP_DIR", tempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasBackupReturnsFalseWhenEmpty() {
|
||||
assertThat(databaseService.hasBackup()).isFalse();
|
||||
assertThat(Files.exists(tempDir)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBackupListReturnsEntries() throws IOException {
|
||||
Path backup =
|
||||
tempDir.resolve(
|
||||
"backup_"
|
||||
+ LocalDateTime.now()
|
||||
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"))
|
||||
+ ".sql");
|
||||
Files.writeString(backup, "CREATE TABLE TEST(ID INT);");
|
||||
|
||||
List<stirling.software.common.model.FileInfo> backups = databaseService.getBackupList();
|
||||
|
||||
assertThat(backups).hasSize(1);
|
||||
assertThat(backups.get(0).getFileName()).isEqualTo(backup.getFileName().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void importDatabaseFromUICopiesBackupAndDeletesTemp() throws IOException {
|
||||
Path script = Files.createTempFile("script", ".sql");
|
||||
Files.writeString(script, "CREATE TABLE SAMPLE(ID INT PRIMARY KEY);\n");
|
||||
|
||||
boolean result = databaseService.importDatabaseFromUI(script);
|
||||
|
||||
assertThat(result).isTrue();
|
||||
assertThat(Files.exists(script)).isFalse();
|
||||
try (var stream = Files.list(tempDir)) {
|
||||
assertThat(stream.toList()).isNotEmpty();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBackupFilePathPreventsTraversal() {
|
||||
assertThatThrownBy(() -> databaseService.getBackupFilePath("../evil.sql"))
|
||||
.isInstanceOf(SecurityException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteBackupFileRejectsInvalidName() throws Exception {
|
||||
boolean deleted = databaseService.deleteBackupFile("..bad.sql");
|
||||
assertThat(deleted).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteAllBackupsRemovesFiles() throws Exception {
|
||||
Path first = tempDir.resolve("backup_first.sql");
|
||||
Path second = tempDir.resolve("backup_second.sql");
|
||||
Files.writeString(first, "SELECT 1;");
|
||||
Files.writeString(second, "SELECT 1;");
|
||||
|
||||
List<org.apache.commons.lang3.tuple.Pair<FileInfo, Boolean>> results =
|
||||
databaseService.deleteAllBackups();
|
||||
|
||||
assertThat(results).hasSize(2);
|
||||
assertThat(results.stream().allMatch(org.apache.commons.lang3.tuple.Pair::getRight))
|
||||
.isTrue();
|
||||
assertThat(Files.exists(first)).isFalse();
|
||||
assertThat(Files.exists(second)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportDatabaseCreatesScript() {
|
||||
databaseService.exportDatabase();
|
||||
|
||||
assertThat(tempDir.toFile().list()).isNotEmpty();
|
||||
verify(notificationService)
|
||||
.notifyBackupsSuccess(
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyString());
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MfaServiceTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private DatabaseServiceInterface databaseService;
|
||||
|
||||
@InjectMocks private MfaService mfaService;
|
||||
|
||||
@Test
|
||||
void setSecretStoresSecretAndDisablesMfa() throws Exception {
|
||||
User user = new User();
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "10");
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
mfaService.setSecret(user, "NEWSECRET");
|
||||
|
||||
assertEquals("NEWSECRET", user.getSettings().get(MfaService.MFA_SECRET_KEY));
|
||||
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void enableMfaSetsEnabledFlag() throws Exception {
|
||||
User user = new User();
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
mfaService.enableMfa(user);
|
||||
|
||||
assertEquals("true", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableMfaClearsSecretAndUsage() throws Exception {
|
||||
User user = new User();
|
||||
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "20");
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
mfaService.disableMfa(user);
|
||||
|
||||
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_SECRET_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void markTotpStepUsedTracksNewestStep() throws Exception {
|
||||
User user = new User();
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
assertTrue(mfaService.markTotpStepUsed(user, 100L));
|
||||
assertEquals("100", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void markTotpStepUsedRejectsReplays() throws Exception {
|
||||
User user = new User();
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "200");
|
||||
|
||||
assertFalse(mfaService.markTotpStepUsed(user, 199L));
|
||||
assertFalse(mfaService.markTotpStepUsed(user, 200L));
|
||||
verify(databaseService, never()).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void markTotpStepUsedIgnoresMalformedStoredValue() throws Exception {
|
||||
User user = new User();
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "not-a-number");
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
assertTrue(mfaService.markTotpStepUsed(user, 5L));
|
||||
assertEquals("5", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
verify(databaseService, times(1)).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isTotpStepUsableRespectsLastStep() {
|
||||
User user = new User();
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "50");
|
||||
|
||||
assertFalse(mfaService.isTotpStepUsable(user, 50));
|
||||
assertFalse(mfaService.isTotpStepUsable(user, 40));
|
||||
assertTrue(mfaService.isTotpStepUsable(user, 51));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isMfaRequiredDefaultsToFalse() {
|
||||
User user = new User();
|
||||
|
||||
assertFalse(mfaService.isMfaRequired(user));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setMfaRequiredStoresFlag() throws Exception {
|
||||
User user = new User();
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
mfaService.setMfaRequired(user, true);
|
||||
|
||||
assertEquals("true", user.getSettings().get(MfaService.MFA_REQUIRED_KEY));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearPendingSecretResetsValues() throws Exception {
|
||||
User user = new User();
|
||||
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "12");
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
mfaService.clearPendingSecret(user);
|
||||
|
||||
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_SECRET_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isMfaEnabledAndGetSecretReadSettings() {
|
||||
User user = new User();
|
||||
user.getSettings().put(MfaService.MFA_ENABLED_KEY, "true");
|
||||
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
|
||||
|
||||
assertTrue(mfaService.isMfaEnabled(user));
|
||||
assertEquals("SECRET", mfaService.getSecret(user));
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.util.Base32Codec;
|
||||
|
||||
class TotpServiceTest {
|
||||
|
||||
private TotpService buildService(String appName) {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
|
||||
ui.setAppNameNavbar(appName);
|
||||
properties.setUi(ui);
|
||||
return new TotpService(properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateSecretReturnsBase32String() {
|
||||
TotpService service = buildService("Test App");
|
||||
|
||||
String secret = service.generateSecret();
|
||||
|
||||
assertNotNull(secret);
|
||||
assertEquals(32, secret.length());
|
||||
assertTrue(secret.matches("[A-Z2-7]+"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildOtpAuthUriIncludesIssuerAndUsername() {
|
||||
TotpService service = buildService("Stirling Test");
|
||||
|
||||
String uri = service.buildOtpAuthUri("user@example.com", "SECRET");
|
||||
|
||||
assertTrue(uri.contains("issuer=Stirling%20Test"));
|
||||
assertTrue(uri.contains("Stirling%20Test%3Auser%40example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidCodeAcceptsCurrentAndAdjacentTimeSteps() throws Exception {
|
||||
TotpService service = buildService("Test App");
|
||||
byte[] secretBytes = "super-secret".getBytes(StandardCharsets.UTF_8);
|
||||
String secret = Base32Codec.encode(secretBytes);
|
||||
|
||||
long timeStep = Instant.now().getEpochSecond() / 30;
|
||||
String currentCode = generateCode(service, secretBytes, timeStep);
|
||||
String nextCode = generateCode(service, secretBytes, timeStep + 1);
|
||||
|
||||
assertTrue(service.isValidCode(secret, currentCode));
|
||||
assertEquals(timeStep, service.getValidTimeStep(secret, currentCode));
|
||||
assertTrue(service.isValidCode(secret, nextCode));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidCodeRejectsInvalidFormats() {
|
||||
TotpService service = buildService("Test App");
|
||||
|
||||
assertFalse(service.isValidCode("SECRET", "ABCDEF"));
|
||||
assertFalse(service.isValidCode("SECRET", "12345"));
|
||||
assertFalse(service.isValidCode(null, "123456"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidCodeRejectsInvalidSecrets() {
|
||||
TotpService service = buildService("Test App");
|
||||
|
||||
assertFalse(service.isValidCode("INVALID*", "123456"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildOtpAuthUriUsesDefaultIssuerWhenMissing() {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
TotpService service = new TotpService(properties);
|
||||
|
||||
String uri = service.buildOtpAuthUri("user@example.com", "SECRET");
|
||||
|
||||
assertTrue(uri.contains("issuer=Stirling%20PDF"));
|
||||
}
|
||||
|
||||
private String generateCode(TotpService service, byte[] secretBytes, long timeStep)
|
||||
throws Exception {
|
||||
Method generateCode =
|
||||
TotpService.class.getDeclaredMethod("generateCode", byte[].class, long.class);
|
||||
generateCode.setAccessible(true);
|
||||
return (String) generateCode.invoke(service, secretBytes, timeStep);
|
||||
}
|
||||
}
|
||||
+118
-230
@@ -1,22 +1,26 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
@@ -29,272 +33,156 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
|
||||
@Mock private TeamRepository teamRepository;
|
||||
|
||||
@Mock private AuthorityRepository authorityRepository;
|
||||
|
||||
@Mock private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Mock private MessageSource messageSource;
|
||||
|
||||
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
|
||||
|
||||
@Mock private SessionPersistentRegistry sessionRegistry;
|
||||
@Mock private DatabaseServiceInterface databaseService;
|
||||
@Mock private ApplicationProperties.Security.OAUTH2 oAuth2;
|
||||
|
||||
@Mock private ApplicationProperties.Security.OAUTH2 oauth2Properties;
|
||||
|
||||
@InjectMocks private UserService userService;
|
||||
|
||||
private Team mockTeam;
|
||||
private User mockUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockTeam = new Team();
|
||||
mockTeam.setId(1L);
|
||||
mockTeam.setName("Test Team");
|
||||
|
||||
mockUser = new User();
|
||||
mockUser.setId(1L);
|
||||
mockUser.setUsername("testuser");
|
||||
mockUser.setEnabled(true);
|
||||
}
|
||||
@Spy @InjectMocks private UserService userService;
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithUsernameAndAuthenticationType_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
AuthenticationType authType = AuthenticationType.WEB;
|
||||
void saveUserCore_populatesFieldsAndPersists()
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
Long teamId = 42L;
|
||||
Team team = new Team();
|
||||
team.setId(teamId);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(team));
|
||||
when(passwordEncoder.encode("plain")).thenReturn("encoded");
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
SaveUserRequest request =
|
||||
SaveUserRequest.builder()
|
||||
.username("validUser")
|
||||
.password("plain")
|
||||
.ssoProviderId("sso-id")
|
||||
.ssoProvider("provider-x")
|
||||
.authenticationType(AuthenticationType.OAUTH2)
|
||||
.teamId(teamId)
|
||||
.role(Role.ADMIN.getRoleId())
|
||||
.firstLogin(true)
|
||||
.enabled(false)
|
||||
.requireMfa(true)
|
||||
.mfaEnabled(true)
|
||||
.mfaSecret("top-secret")
|
||||
.mfaLastUsedStep(5L)
|
||||
.build();
|
||||
|
||||
// When
|
||||
userService.saveUser(username, authType);
|
||||
User saved = userService.saveUserCore(request);
|
||||
|
||||
// Then
|
||||
verify(userRepository).save(any(User.class));
|
||||
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
|
||||
verify(userRepository).save(userCaptor.capture());
|
||||
verify(databaseService).exportDatabase();
|
||||
|
||||
User persisted = userCaptor.getValue();
|
||||
assertEquals("validUser", persisted.getUsername());
|
||||
assertEquals("encoded", persisted.getPassword());
|
||||
assertEquals("provider-x", persisted.getSsoProvider());
|
||||
assertEquals("sso-id", persisted.getSsoProviderId());
|
||||
assertEquals(team, persisted.getTeam());
|
||||
assertEquals("oauth2", persisted.getAuthenticationType());
|
||||
assertFalse(persisted.isEnabled());
|
||||
assertTrue(persisted.isFirstLogin());
|
||||
assertTrue(
|
||||
persisted.getAuthorities().stream()
|
||||
.anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority())));
|
||||
assertEquals(
|
||||
"true",
|
||||
persisted.getSettings().get(MfaService.MFA_REQUIRED_KEY),
|
||||
"MFA requirement should be stored");
|
||||
assertEquals(
|
||||
"true",
|
||||
persisted.getSettings().get(MfaService.MFA_ENABLED_KEY),
|
||||
"MFA enabled flag should be stored");
|
||||
assertEquals(
|
||||
"top-secret",
|
||||
persisted.getSettings().get(MfaService.MFA_SECRET_KEY),
|
||||
"MFA secret should be stored");
|
||||
assertEquals(
|
||||
"5",
|
||||
persisted.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY),
|
||||
"MFA last used step should be stored");
|
||||
assertSame(saved, persisted, "Returned user should be the persisted instance");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithUsernamePasswordAndTeamId_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
Long teamId = 1L;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
void saveUserCore_withoutTeam_usesDefaultTeam()
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
Team defaultTeam = new Team();
|
||||
defaultTeam.setName("Default");
|
||||
when(teamRepository.findByName("Default")).thenReturn(Optional.of(defaultTeam));
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
SaveUserRequest request = SaveUserRequest.builder().username("anotherUser").build();
|
||||
|
||||
// When
|
||||
User result = userService.saveUser(username, password, teamId);
|
||||
User saved = userService.saveUserCore(request);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
verify(passwordEncoder).encode(password);
|
||||
verify(teamRepository).findById(teamId);
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(teamRepository).findByName("Default");
|
||||
verify(teamRepository, never()).findById(anyLong());
|
||||
verify(databaseService).exportDatabase();
|
||||
assertEquals(defaultTeam, saved.getTeam(), "Default team should be applied");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithTeamAndRole_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
String role = Role.ADMIN.getRoleId();
|
||||
boolean firstLogin = true;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
void processSSOPostLogin_autoCreatesUserWhenMissing()
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
String username = "autoUser";
|
||||
doReturn(true).when(userService).isUsernameValid(username);
|
||||
when(userRepository.findBySsoProviderAndSsoProviderId("prov", "id"))
|
||||
.thenReturn(Optional.empty());
|
||||
when(userRepository.findByUsernameIgnoreCase(username)).thenReturn(Optional.empty());
|
||||
User created = new User();
|
||||
doReturn(created).when(userService).saveUserCore(any(SaveUserRequest.class));
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
userService.processSSOPostLogin(username, "id", "prov", true, AuthenticationType.SAML2);
|
||||
|
||||
// When
|
||||
User result = userService.saveUser(username, password, mockTeam, role, firstLogin);
|
||||
ArgumentCaptor<SaveUserRequest> reqCaptor = ArgumentCaptor.forClass(SaveUserRequest.class);
|
||||
verify(userService).saveUserCore(reqCaptor.capture());
|
||||
SaveUserRequest captured = reqCaptor.getValue();
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
verify(passwordEncoder).encode(password);
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
assertEquals(username, captured.getUsername());
|
||||
assertEquals("id", captured.getSsoProviderId());
|
||||
assertEquals("prov", captured.getSsoProvider());
|
||||
assertEquals(AuthenticationType.SAML2, captured.getAuthenticationType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithInvalidUsername_ThrowsException() throws Exception {
|
||||
// Given
|
||||
String invalidUsername = "ab"; // Too short (less than 3 characters)
|
||||
AuthenticationType authType = AuthenticationType.WEB;
|
||||
void addApiKeyToUserGeneratesAndPersists() {
|
||||
User user = new User();
|
||||
user.setUsername("user");
|
||||
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
// When & Then
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> userService.saveUser(invalidUsername, authType));
|
||||
User updated = userService.addApiKeyToUser("user");
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
verify(databaseService, never()).exportDatabase();
|
||||
assertNotNull(updated.getApiKey());
|
||||
verify(userRepository).save(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithNullPassword_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
Long teamId = 1L;
|
||||
void getApiKeyForUserCreatesWhenMissing() {
|
||||
User user = new User();
|
||||
user.setUsername("user");
|
||||
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
String apiKey = userService.getApiKeyForUser("user");
|
||||
|
||||
// When
|
||||
User result = userService.saveUser(username, null, teamId);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
verify(passwordEncoder, never()).encode(anyString());
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
assertNotNull(apiKey);
|
||||
verify(userRepository).save(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithEmptyPassword_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String emptyPassword = "";
|
||||
Long teamId = 1L;
|
||||
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
|
||||
// When
|
||||
User result = userService.saveUser(username, emptyPassword, teamId);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
verify(passwordEncoder, never()).encode(anyString());
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithValidEmail_Success() throws Exception {
|
||||
// Given
|
||||
String emailUsername = "test@example.com";
|
||||
AuthenticationType authType = AuthenticationType.OAUTH2;
|
||||
|
||||
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
|
||||
// When
|
||||
userService.saveUser(emailUsername, authType);
|
||||
|
||||
// Then
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithReservedUsername_ThrowsException() throws Exception {
|
||||
// Given
|
||||
String reservedUsername = "all_users";
|
||||
AuthenticationType authType = AuthenticationType.WEB;
|
||||
|
||||
// When & Then
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> userService.saveUser(reservedUsername, authType));
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
verify(databaseService, never()).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithAnonymousUser_ThrowsException() throws Exception {
|
||||
// Given
|
||||
String anonymousUsername = "anonymoususer";
|
||||
AuthenticationType authType = AuthenticationType.WEB;
|
||||
|
||||
// When & Then
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> userService.saveUser(anonymousUsername, authType));
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
verify(databaseService, never()).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_DatabaseExportThrowsException_StillSavesUser() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
Long teamId = 1L;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doThrow(new SQLException("Database export failed")).when(databaseService).exportDatabase();
|
||||
|
||||
// When & Then
|
||||
assertThrows(SQLException.class, () -> userService.saveUser(username, password, teamId));
|
||||
|
||||
// Verify user was still saved before the exception
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithFirstLoginFlag_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
Long teamId = 1L;
|
||||
boolean firstLogin = true;
|
||||
boolean enabled = false;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
|
||||
// When
|
||||
userService.saveUser(username, password, teamId, firstLogin, enabled);
|
||||
|
||||
// Then
|
||||
verify(passwordEncoder).encode(password);
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithCustomRole_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
Long teamId = 1L;
|
||||
String customRole = Role.LIMITED_API_USER.getRoleId();
|
||||
String encodedPassword = "encodedPassword123";
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
|
||||
// When
|
||||
userService.saveUser(username, password, teamId, customRole);
|
||||
|
||||
// Then
|
||||
verify(passwordEncoder).encode(password);
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
void isUsernameValidRejectsReservedAndAcceptsEmail() {
|
||||
assertFalse(userService.isUsernameValid("ALL_USERS"));
|
||||
assertTrue(userService.isUsernameValid("valid@example.com"));
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package stirling.software.proprietary.security.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class Base32CodecTest {
|
||||
|
||||
@Test
|
||||
void encodeReturnsEmptyStringForEmptyInput() {
|
||||
assertEquals("", Base32Codec.encode(new byte[0]));
|
||||
assertEquals("", Base32Codec.encode(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeReturnsEmptyArrayForBlankInput() {
|
||||
assertArrayEquals(new byte[0], Base32Codec.decode(null));
|
||||
assertArrayEquals(new byte[0], Base32Codec.decode(""));
|
||||
assertArrayEquals(new byte[0], Base32Codec.decode(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void encodeDecodeRoundTrip() {
|
||||
byte[] input = "hello world".getBytes(StandardCharsets.UTF_8);
|
||||
String encoded = Base32Codec.encode(input);
|
||||
byte[] decoded = Base32Codec.decode(encoded);
|
||||
|
||||
assertArrayEquals(input, decoded);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeAcceptsPaddingSpacesAndLowercase() {
|
||||
byte[] decoded = Base32Codec.decode("mzxw6y tboi====");
|
||||
assertEquals("foobar", new String(decoded, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeRejectsInvalidCharacters() {
|
||||
assertThrows(IllegalArgumentException.class, () -> Base32Codec.decode("MZXW6$"));
|
||||
}
|
||||
}
|
||||
+16
-12
@@ -2,7 +2,7 @@ plugins {
|
||||
id "java"
|
||||
id "jacoco"
|
||||
id "io.spring.dependency-management" version "1.1.7"
|
||||
id "org.springframework.boot" version "3.5.7"
|
||||
id "org.springframework.boot" version "3.5.9"
|
||||
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
|
||||
id "io.swagger.swaggerhub" version "1.3.2"
|
||||
id "com.diffplug.spotless" version "8.1.0"
|
||||
@@ -18,7 +18,7 @@ import groovy.xml.XmlSlurper
|
||||
import org.gradle.api.tasks.testing.Test
|
||||
|
||||
ext {
|
||||
springBootVersion = "3.5.7"
|
||||
springBootVersion = "3.5.9"
|
||||
pdfboxVersion = "3.0.6"
|
||||
imageioVersion = "3.13.0"
|
||||
lombokVersion = "1.18.42"
|
||||
@@ -27,7 +27,7 @@ ext {
|
||||
openSamlVersion = "4.3.2"
|
||||
commonmarkVersion = "0.27.0"
|
||||
googleJavaFormatVersion = "1.28.0"
|
||||
logback = "1.5.23"
|
||||
logback = "1.5.25"
|
||||
junitPlatformVersion = "1.12.2"
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.3.1'
|
||||
version = '2.4.3'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
@@ -102,12 +102,14 @@ def updateSimulationVersion(File fileToUpdate, String version) {
|
||||
writeIfChanged(fileToUpdate, updatedContent)
|
||||
}
|
||||
|
||||
def rootProjectRef = project
|
||||
|
||||
tasks.register('syncAppVersion') {
|
||||
group = 'versioning'
|
||||
description = 'Synchronizes app version across desktop and simulation configs.'
|
||||
|
||||
doLast {
|
||||
def appVersion = project.version.toString()
|
||||
def appVersion = rootProjectRef.version.toString()
|
||||
println "Synchronizing application version to ${appVersion}"
|
||||
updateTauriConfigVersion(appVersion)
|
||||
|
||||
@@ -149,18 +151,18 @@ subprojects {
|
||||
repositories {
|
||||
if (!rootProject.ext.mavenUrl.isEmpty()) {
|
||||
maven {
|
||||
url rootProject.ext.mavenUrl + '/releases'
|
||||
url = rootProject.ext.mavenUrl + '/releases'
|
||||
credentials(PasswordCredentials) {
|
||||
username rootProject.ext.username
|
||||
password rootProject.ext.password
|
||||
username = rootProject.ext.username
|
||||
password = rootProject.ext.password
|
||||
}
|
||||
authentication {
|
||||
basic(BasicAuthentication)
|
||||
}
|
||||
allowInsecureProtocol true
|
||||
allowInsecureProtocol = true
|
||||
}
|
||||
}
|
||||
maven { url "https://build.shibboleth.net/maven/releases" }
|
||||
maven { url = "https://build.shibboleth.net/maven/releases" }
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
@@ -397,8 +399,10 @@ tasks.withType(JavaCompile).configureEach {
|
||||
|
||||
gradle.taskGraph.whenReady { graph ->
|
||||
if (project.hasProperty("noSpotless")) {
|
||||
tasks.matching { it.name.startsWith("spotless") }.configureEach {
|
||||
enabled = false
|
||||
allprojects { scopedProject ->
|
||||
scopedProject.tasks.matching { it.name.startsWith("spotless") }.configureEach {
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ COPY . .
|
||||
|
||||
# Build server-only JAR (no frontend, includes security features controlled by DOCKER_ENABLE_SECURITY at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 3: Final unified image
|
||||
|
||||
@@ -30,7 +30,6 @@ WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=true \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 3: Final unified ultra-lite image
|
||||
|
||||
@@ -25,7 +25,6 @@ COPY . .
|
||||
|
||||
# Build the application (server-only JAR - no UI, includes security features controlled at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# ========================================
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user