feat: add managed ISAC image library
This commit is contained in:
+13
-12
@@ -5,21 +5,22 @@
|
|||||||
DOTENV_PUBLIC_KEY_DEVELOPMENT="038fa4f9f7abd799fabebc69cb4d6a42a2ea361c3ec799580ddd213bcafac5c167"
|
DOTENV_PUBLIC_KEY_DEVELOPMENT="038fa4f9f7abd799fabebc69cb4d6a42a2ea361c3ec799580ddd213bcafac5c167"
|
||||||
|
|
||||||
# .env.development
|
# .env.development
|
||||||
TDR_DATABASE_PATH=encrypted:BNTRek+Cz9NI9Qasv0a+BWUNs5u5qTESE+1U7fybMS3NJLQkY8WR/eQ0v8/ZoiiHLSM/EGum5NRNC/iRSYIjkjFTvfC4xFnUXALvy3zM2Iw4AezGWdmOZ6RI5csNtGQKQ0idOicuA1YN3FpcDdOSSZXTOyVniwFduWcAZTuBPTo=
|
TDR_DATABASE_PATH=encrypted:BDdEUO0HX3ZgfPHXCtNAjISsKdb3IZlOGDZE2vSmAGm4Y2+UgLAoOX8Ij+CL/GJUD0IVU0HUsTyEx6CrXutXEd1uWz7dxqgtra59jmBKasN6ucrgaY/8ktRq/uoTm/t99xyuEWRXJdZMFSk7YkbkQuHgvoU5K3nqI2ml0mEFeL4=
|
||||||
DISCORD_CLIENT_ID=encrypted:BDuk9O2rhgVwi6/NqnG1+n2Jeb/A6ciQwNAdY3Ih7R08DhTF4g2d9ZKpg+OzmIL7BeZF3ikkedlfljxn8xbW8f9YGrrfaLLdTDVClyT+scyfcKeaK6uunQVvipG5UwhotEd3VS8T9IcQyNDVq9y9SEgIjyo=
|
DISCORD_CLIENT_ID=encrypted:BA9XZlnAHz/glIBFqEbROTvLmgW1ZzONZ+ZPVHyw6hQB7U6YxnYfdmQ+HJnLkgkJyib6PFc+AijW11xgd5B8Z3BvIsXjzjs29fFs4yYnEVbxvoBD2KB2XJHtqabdVIYq9dFjfG/zY1hb3CIi8FSgMVFxxBw=
|
||||||
DISCORD_CLIENT_SECRET=encrypted:BE3+3NfbuQpjbpc8Pyl0e5YEW478dOKdi5cWuwHjJ5L2z/X+1hJ+B6ag4oXwPBt20t12qWtv5IYPJGMb+Uzk6hZLazqkHxl+DUozeNMAOOrNHESPiTwH7fpI0vf4oVTWH+j4iYMRUlmLkjzOICTmAL+ljOJgi5dTqHx05unXGqxZ
|
DISCORD_CLIENT_SECRET=encrypted:BHhFIIU5v7Ljz9LtAR3z6Y6qAswOtYDKtcvXW4EXAs85OiZ2sjfQ1VNoBtkg2wtevEmTvxLoyq1EEBcSIAmAIUeBaJPZa60FNK0nU7y/GaUvqV+Q8G3V7AkRmoptiOZ4jduiPzlzs3+jNvJmrf6KEnYWKx4ytEQpADO75BgDhoCS
|
||||||
DISCORD_REDIRECT_URI=encrypted:BEWUNH2YlygQ23TBiXj9lgJ7hijU13YJhiBYVM1VEHHM3s8ryqWJ+hz2bJ0EyGHg3XU9UBPDhmgLkafULYzY/KgOLmSMq6TkrYq1e7Qec+K70ZyqoNp7L1s9C0EK//dpkE1iMvd92bhLPOeS8sOhFN4dyYbK2vPGRdi7AKxFwiU/jq7pbwkHeU5DQXU=
|
DISCORD_REDIRECT_URI=encrypted:BL1YDjAhLKcVVwiBUzjkHt9wLuM6bZJXl1RnZO0DWanGa39LjiQ3ybJFN0I2jNVgcVjqk3nSiCYDGOQt49UeF08aMdJkOa67SVR9yDtESJtMKlPJxL/HaLf8eAex4DlO/R7NlwDRUua5pRBcu0TH6UeC7FphlYNGRFPwfcuso32Hg+Fl5f1CKU4tUTQ=
|
||||||
|
|
||||||
# Passwordless authentication
|
# Passwordless authentication
|
||||||
APP_ORIGIN=encrypted:BE7igk/cyE/Wv72Rc8A0O815n/uDyyaKdLwdonSjrc7rHWiwBifF8S6EQ5tUkH8i0eQsxLGxcAJTW0H9gfGw0Nftxl+1Mr2slGLZJxxiJ4Upp1CU3pLwIgcTg82ew8ewOvfYnT6mVLPMHO/02vomjOxYobLKdQ==
|
APP_ORIGIN=encrypted:BNe3AfHVmq+afhm6TwmoT+SDn+oUfz0S0NgP7rjh+o0mHizYiq0/pInyIVarq0Z7WTwlhJIW0SCE72DMoQvO2mIEdnO4jOxXOZywTpcMHpWZ8fBgVNC5wlH+KYnBmH5MWg0fjrPALXNpTAyYGoIiB1WiuiDrpA==
|
||||||
PASSKEY_RP_ID=encrypted:BNMQdCdZR9e47vjPfme83ujNlJqsmJgGeri8r+uuJ1MwHeWXuv8FKi+XHKKX8GaXvEdqD0mLSFyYvUMgrzJb/ZwEo/B77opQ67AXFtERz4qUKDc6XdOvFNRUvlPjPaO/eoyqr64WIEjG3w==
|
PASSKEY_RP_ID=encrypted:BMDp5uRV/HQ6BaqEOODDXYrrukSRNMVrUzr56yoMUkJwIogCT+SmedS/pXJGvi7er8PvJGSfC1SoX8G+YiUFua/dcSERXFVGRg+p8LywPZDN/YuEfv5p6GuRgQmd/rcPvP5TUqhC0SEPIw==
|
||||||
|
|
||||||
# Shared SMTP account for magic-link and email-verification delivery.
|
# Shared SMTP account for magic-link and email-verification delivery.
|
||||||
# Implicit TLS/SSL normally uses port 465. For STARTTLS on port 587, set
|
# Implicit TLS/SSL normally uses port 465. For STARTTLS on port 587, set
|
||||||
# SMTP_SECURE=false.
|
# SMTP_SECURE=false.
|
||||||
SMTP_HOST=encrypted:BGk17DA/BpNINXIl4d7LKTLi3IfAjvyk1OhP5yPnk6ZjE3FHSJl3ed6tQh5oPoUt8zovm+hyV2rD/Pae+axnWdxKXtUohp5HkqNqC1T4SEeVWpUsx5m/xXt8hxcwEQ/1HfzTcdx41wo/hR2ereZMFaX7sA==
|
SMTP_HOST=encrypted:BPyC/VDuZ5nvNssrmooa1yMlQ2DJUBTViB+84o+RdmzKaIYtHSw5ZkIAA8uNjDyCGSKtmf/6TCDD2CCz6IOCcLgkT3e6ycYVp1Sa7LCqeLNK3azR1QIRzS9zwEq5txtuzdDK5IvzUNqNdduR0PlZZrJNvQ==
|
||||||
SMTP_PORT=encrypted:BBzjlvppArJeJNKCeNyuf4+bX4ohICRycdb/BbZTryN114iZ1d6wyQr6Gre2Eg9fhTQqdIiDjjqpb9UWbjavutlHFZ0UarMUWaOBRfVd9+sqmXI8/vmA6Scp8z4MxteqB5+6jQ==
|
SMTP_PORT=encrypted:BGMe8boYWFP3ZnDGSQYOKBfDne+0ylzmONtpqslJsv6FhrhB7v7Tho7fSdOMvC7/F987J36eKP/dxyje8uWCAA8D/305HiIm4I78nPd9rJ3x1WVLJGO22TZfUJDIdbBF4WR04w==
|
||||||
SMTP_SECURE=encrypted:BBMBvqEuiHmFAy+C56yr/g1kPQsVHu+xO5eVtHis4vdAHHsGVO/blfVMHDw0PAiHPFITHDrp7GjNnvQSq0AKgLK+Mn2/Iv7XC9oQedoplQFOY4kNuM77qWJur/H93DgtMckkfNB+
|
SMTP_SECURE=encrypted:BJA9tMvTfopV/UTxmrfRt3Xaf73VokM3TPTg4X+NEPjk7C8wrifPcRbFsA0bWz3XWAzHoGTPCICQm2iaPh/KkVlpY0KE2ZUME9u1RirhjngByaK4m+bFfSHg2J9NRffGDTtevMvE
|
||||||
SMTP_USER=encrypted:BHmiGy4eBiIOP5PLYBsqomWDCc7DSFvmfXPyPtD6/Cj0KLQAWaVVQowQbsc0XuvuOhesEAcrMBtAD0+rsAmAhoHvdudqc7jPXk0IAFeeasAp8CWvPzd2Afb6tI/Z2xv5BxVsR6l4p1uh+SlK0lfskBGSdQ==
|
SMTP_USER=encrypted:BKeUeUDV0hfc79vQuUrmMPJbIs2yFzfupaIXjSBlgJzuQtTqX6cQPIUV+4xIoO8pycOrf5esF7YN7JZXdgZFhULPyTW/ufBkMmDffkrH2l5aOtdV6EArXdHzo/V76UNr7/2Kzx5aO+3CcxvKwOHH9/WReQ==
|
||||||
SMTP_PASSWORD=encrypted:BJ8mNgq9j9mmsptaEP8L47MbuB/l9jmkMGXco5ybw91a/vZFyZ5xLWTUxdVYYlHiBIAhjs1eEsx7dQgc4+5wmGFyNfDILod5Turp/OqDD8qV2c7wsKGzOrV6TCGELscoxiHg28YCZCriZnIs9Uw9yRE=
|
SMTP_PASSWORD=encrypted:BFmv9RUty2dlzHo1wm8bbqDECJG/npi8hciwAu7jzssSCU28hFXPiPeAdTw6thCAlLAm2ECR2z3Oopn1I77wJZDAh1N5JRZ2eybgE205VDLq4NnbqwH1tljDwlyDY9z+6uBGtoANnnW/DR8GPZwAoWw=
|
||||||
SMTP_FROM=encrypted:BGOoIMcTZ8Wknq81asUOHBtHsqKmUeVq8SDdvp+F5lWKwWxSos8cE/inhJk8gfVEp8cf6m8xVZQt+qUh0lscKA5LPpyixnFe1AyHRAlfqc+r2200492HxHrwKZfEzZ9RFMlN7omgC8ADAZ6eo5mvT/udqHWJpIQLJNSV5GFgjw==
|
SMTP_FROM=encrypted:BMQEkKEg7FbSqbF5LZwUYWMbcFLBXuVoJ5zpGGnCkSyrV7fuA4wXZ7lBjQz+b9iMNmhDxO9n7lsVRARkXQVHWLx3lUTdnfrh3dzphfTqG6TVBaWX+JIkrN+V4MRYyopLPqh4ZfrirZPz1qq6q/1HZcSmSqC6NQF6dBJQBHzCJQ==
|
||||||
|
REMOVE_BG_API_KEY=encrypted:BLhGrWHuRJsZ0sbBiGpZpoYecsJpw4l+jVnocSJWl9kszTgewHl29FPZXS2FEJUg0NRGVpuNvT6+l9k69pO8vO6UGl4tngNu/bzcZaDGMdTfAlS4Q9KvLQIaYs7DzlE7tlrfaFom9XGCSWoLEDnAhwtglZaz4xMd8A==
|
||||||
|
|||||||
@@ -40,3 +40,4 @@ SMTP_FROM=SHD.build <no-reply@shd.build>
|
|||||||
|
|
||||||
# Long random bearer token used only by the internal container scheduler.
|
# Long random bearer token used only by the internal container scheduler.
|
||||||
SCHEDULER_TOKEN=
|
SCHEDULER_TOKEN=
|
||||||
|
REMOVE_BG_API_KEY=
|
||||||
|
|||||||
+13
-12
@@ -5,21 +5,22 @@
|
|||||||
DOTENV_PUBLIC_KEY_PRODUCTION="034d799df86cb16852742f1b2a42c69dde86396ddce96448c429c6ba68034a331d"
|
DOTENV_PUBLIC_KEY_PRODUCTION="034d799df86cb16852742f1b2a42c69dde86396ddce96448c429c6ba68034a331d"
|
||||||
|
|
||||||
# .env.production
|
# .env.production
|
||||||
TDR_DATABASE_PATH=encrypted:BE4lCm2/hLx3wEPCPvmIWHEPHS2oVLZHcWS6e4FmRwwkSR7GxaifHgccy32IInzWXiUp8P8OnZS6MYJn5X04wKtWV0y4FfbiCGs0e8YpM1Fo513hd1LmB6uDZo5pkacJV07KpG8NV01RveoHhe/ANN/UVwStFc7eZj85G4cu6A==
|
TDR_DATABASE_PATH=encrypted:BHIvIe9XAoIG5lp+e2weR7nRX3Q6s38O9IqDVFjk9IikerZvrwVBSxDl0g85lrcP36RG69bCjWHxpZxTpbTHdimh+sSbM+b743VM3w++0Z22VSf8vyVM11Mp6KfLG2GwId3ExX2z+ReavxqdczjirHBAvzbGWliDPfP3zey1vQ==
|
||||||
DISCORD_CLIENT_ID=encrypted:BK2ZUIggBGYBCU3zEaSOmo2mxqiWnOKtdKxK7FWTw56bG3xFoXtmSZ/vyz+OF/pLd6ciS/WO1nAxC+FND4mLqdZfm99MrWacQj79///zB+vsouDdwg3zqS76dxQ1zES4lPTzk+fhRqHe/EsqQK4sweax4fc=
|
DISCORD_CLIENT_ID=encrypted:BOJFAQzTZp0HWmZzMMHrgHwq6iqu8k7HjLQ/UDD3Z7vMsR8N7denoVb8MhTOw/0Qj9bvJ9AENBiXTBlwY9F0z03xH+4+E6Hh6z0DzDF+GU6crTtNPjOdMIFgWM2NLRZM5pl/z7giHZmk0uBVDfP3H6S1vXc=
|
||||||
DISCORD_CLIENT_SECRET=encrypted:BAwObVwDUb5HMWun2WUYpm3zrqFQHegE1KQnfq5I9tglZQXl6/XxkwHtNQFmoU0WgpsJERT1N+yO+0CtBzhdpufIHyXqjCj8uYD70wJvXY2BqEkth0JgKcMi7lO7gSgtybnzUGTDS0xLqjpBgw9lkl0rCun7axH/gkrd/rUEXLdF
|
DISCORD_CLIENT_SECRET=encrypted:BCa9bbGfHMNn60xvnpiHx93M0t33eqoMR4c5K2J2esrUSnfh4nY/vwdY5JV1vH/G1DH5VG2RJ+v2i0/T7GPMJHD0ErvrS4pB6evBS4BvBFAECwnx2lnO0zC9X8YzmRGrIFjW2zZFp1kINMqCc8t9L3GoHV2jFuLOOxJ0jRWnVVAd
|
||||||
DISCORD_REDIRECT_URI=encrypted:BGgy7YS2iU62+mSDlUDNwvZhE39Pr2o2kpnFJCg/16MaQK/VoZVyICSuLBuFE0snAUb8KIvcnklusqao8TU1izHeTS+rvBMLvRVzcyHDTZUynrmn4UCzG5w4phiTVi2OhaZvw8hQSkcvoV57mVtBo7LNEAuVEhuv5WAeOHOX9oaV4qYfKVviRQ==
|
DISCORD_REDIRECT_URI=encrypted:BG+znHavQa2TTpCZh9KCLPAQr3vpqyraygRgfxBXMNym1/9UepTrtHuWGrdymdUHS9xKkpvutuWZW3Nlb9X+359izBfJEW5pXOcUnXNE5FOukFgYnmJVcGU1qglwufmIeJc/qqa5AGvyzzO1CkxkblO91+g7fk/DWWhrsSHlqGZkx5ULyZziiw==
|
||||||
|
|
||||||
# Passwordless authentication
|
# Passwordless authentication
|
||||||
APP_ORIGIN=encrypted:BDJyyjEXFeEjdJiHOGRlxpiWkbzFJBZnM6w1VAb8m4YRW+c0Jvg4kl1XzaRtf63TLjfNWVxxwhCBwJ9s5qeP2+O7FvbgKKxoEXeFyP+gBUDsXss8volhd7toPaF9sjdAtpXY5L0M7SVYOct+FQCHAoyX
|
APP_ORIGIN=encrypted:BFwQW4kaK11NK/UnBLjgwATEcLnAjvR9fFWCmxphBiJHNSYGTBBmpAvhZGvVoA4p/Q9fLP3el+BWgsg2Y3uFajL5oLSMx04UagGm/kX2K1PKztiDyeS1U8qSVQHeVUBtRXpnH7ugbfbLBg9IgXKHUS1L
|
||||||
PASSKEY_RP_ID=encrypted:BKvPDWDsTEwhAtFAsKENg85D4Klo+Ipp6KnZ0EZ1Nlyi6ikuswRqDFrqYtlleBhzwAHhl7/uD3P6e2cyaNvffokcwQQQb8CXhCVRyGFgy1htvpFXhMmdesMfuqoVZ87wb5ngDlHKKV3E2A==
|
PASSKEY_RP_ID=encrypted:BPR2a1uPeIaD9KRQAp/6n5h0dE9cDlGMqZtx/asRbey7eeTs2N9ZM5HYvXG2pnIyxO30aD4NrNh7IvJrb52qCXObDdBB0LXn7rv1VYdCQOVxgvpcV+mOb7bECORvm6TGxBTYnf8QwRH+Tw==
|
||||||
|
|
||||||
# Shared SMTP account for magic-link and email-verification delivery.
|
# Shared SMTP account for magic-link and email-verification delivery.
|
||||||
# Implicit TLS/SSL normally uses port 465. For STARTTLS on port 587, set
|
# Implicit TLS/SSL normally uses port 465. For STARTTLS on port 587, set
|
||||||
# SMTP_SECURE=false.
|
# SMTP_SECURE=false.
|
||||||
SMTP_HOST=encrypted:BNKsoY8sz2Kf1ZaRtE1pU7JQM36Qj+KADK23R+pwVYV3RmeO6OP6AtGVGjDoHoPAqCsoSLPLM0INyX9XFdEJP+DtN0EVVoHgXt4+fAd1kXDBZTwZ+NEgFP6E6h8V5gZdMhar6hrnWecGfrFzs2c/1+e5Kg==
|
SMTP_HOST=encrypted:BIjGLiRygyi55OmuLVK8LH0rgUurPZSrM7dom9IG7uE0Cpa9jVoPUek3p3MIyUUyN8SwdDRnr0+WGQsdR47YVnUcRSVVIx1K1HtTre+O6S60QLbPrk7Yv4ET4nl2kmaovPHalp7TpTcZpG1ZcaPy0LQEdQ==
|
||||||
SMTP_PORT=encrypted:BDd5baDM0FWRaEDV/5s94/J8a2Ll9ZxLhBy5NVytdKXPbSq40E9UPKHPNS9vqet69Qceq7rzXgLmcdiqkqp0HdlocJUWzA5YIYcY5Er31o7nau7SKAk0KfnzHA6VVsvRDWxo/w==
|
SMTP_PORT=encrypted:BBwsMpX2peGY35Bxv6hFEYlRWouITB7H7HU7/oyA65inKbY0W/JmJIeAng7faJnw6YbUWH9aT8+ztwaBTdtO6VRFclbZcQiJv5MuRHIgHPeydHrH0+Wl5yXaXAiT7iY5itRqug==
|
||||||
SMTP_SECURE=encrypted:BPH6vl2VFiVF0m1sE5Ybt8rrM2TjkgEO5pKnu+5P++fiLkTnEr3bp5tAPbhqWAXpmgmXt2SB2KK/j9hH4Ih0/1cGNvSrqee5FNpwHKncrxrVRzdwSuIgYViziwCrReKXxR+uhzVD
|
SMTP_SECURE=encrypted:BKVehx9fXwB3B0OFnFnIFn5vdMa4Jx2zFtxpkHUNpnxHfJ9zHbI+9RqikZWzwqYV/vKS5KCWsLIIUH2Y+xdodhWeIlvjbYtHB2nkigc7l3xkXJ06iVL/DGWSBytkqZQAz187pW6M
|
||||||
SMTP_USER=encrypted:BKWljaiPijrWfVy5QHxWC83SCzs/MUlzFnI0i+CG19ZSfJ3dUqIdnACqjkpuYI5T/tO1AXB2kOptDc6M7foGdUZ1uDHzTWVOUl5yt0kx6rlAPExuRD9PmOdWvH/CzDQvn6MblJumUtZ5XgNTXG6RgZBqGg==
|
SMTP_USER=encrypted:BLsCJXx0nJBw15JpIqZM+I+EmcfA3yOCr9cAr7msQjsWEYOlRYJQFHQ5mJqwak250QiZl9js+H4NA5idr3HYif1/JkBjcmJyRLc2Xn0ma8IeiwKZqKA0uRM/P0YjQ1+Ez8yO4SYjMEdAoMcAgQldi5K94w==
|
||||||
SMTP_PASSWORD=encrypted:BO10G2D7osxIP5jZP9SQbMFMvcmKJ6Pd4jFzWa2oHnz7RjY5pbRX7r+jFddm0lZqgj6jVJnJiFPMxKSpuSKJUoHKgXL36z5LcmwUuILysyKJAWj5HoeDOGzsgASz0pezESsDoWeH1+0iWPbw6A0AmUA=
|
SMTP_PASSWORD=encrypted:BOXbGNPqBrJpKzSub0j1rDztZOj83JlJSt6blUeqkC7axIGGAjzGbiZeiQ2lrxTADnr0y10LQzz2i4Rw5pj3FsF7sSLko3sHnz7iqK2Foy5ZeSwtX0AgeD3CoV3DyGdl1awl4RhbS45j8JoThzekoqg=
|
||||||
SMTP_FROM=encrypted:BJmIwi0QgCDhR6cxkHP5EHsWZznB8ixNaHYxFfWMoVGTgHeTwmrXRDPsXBPXBXUMJkpr6yRAOpeADQjJ4TyqhQVw7R1ghk8nzuESH3/oVxPtpBFYaNkahDHMna+x4ZjsRQFwaisFcLm3RPhPQ8WRfR4VtxUjAnSRyfiXNVflsg==
|
SMTP_FROM=encrypted:BLhAJc0wdRAFRfIHHoq7gvksB2Aus0mV6vVq9BRqznvqHC3OKHHVlCknSD9i95NAkJf4rpaCHahCCu4QIN6Iq94s9+Bj3lnQJuWPt5N67ghegeNeRcHSkq+OomadpJYv6PDDUmJs+Yoc6QkVjWjPVMi4suYZE/V/k17vmGzDZg==
|
||||||
|
REMOVE_BG_API_KEY=encrypted:BBufDrwuHcLE+i64HX8G0uH2L/MPZF5ZYbLRXNC+EVClzpMnrYpPXpsBJCRVmWJ2f47pH3cEY5dIaTM7JrDhomS/znF54zMwRyszSnlzMcppEEkyN2FicJ/NWR9woOQOGTbtqKdePCMqS3SU+Zq/fNKgvK1Y+COHDQ==
|
||||||
|
|||||||
+17
-16
@@ -6,23 +6,24 @@ DOTENV_PUBLIC_KEY_STAGING="020bab072314925eb9a9cbaf42282e3a0d5c4a8de3d88a51ed92b
|
|||||||
|
|
||||||
# .env.staging
|
# .env.staging
|
||||||
# Staging is deployed at https://stage.shd.build.
|
# Staging is deployed at https://stage.shd.build.
|
||||||
TDR_DATABASE_PATH=encrypted:BEfWarm0yphwbJYuA9UCHZDY4pMcFw1HLHras694Tj2njTVgIuYcJZ2pJEkj8rjWbtFrCGh052GTqf0pX6KmWXTeu0WWgEanSJiRXZ8CYRt8/rif9gk/bqHOq1CUZfG01+eCIqkN1pOEGVAJEsqjkn/EbiWXzcqBtXisBOYdl40=
|
TDR_DATABASE_PATH=encrypted:BLYcX+Rghxqp069PQEWL+9tRPGUfs/ae0D5ga7bNC4Y0llIwGFcd1pq6JqelF/hSkG+3bdy2anahWeU7bY8t6EjVTFobQ0LlQvQbpBwRKwHgDGEwESovjhajDuPv3b62hfuh9fqVdODR3Csu8ndmlq8SiObT3JgyyzljIZqDwMQ=
|
||||||
TDR_AVATAR_STORAGE_PATH=encrypted:BMoxUtKT0LXRmZMrj776svimltQlS7TEhv3ganVjJEbTS+4WlGDHzqxQ91+6nUexyXxeBWW7n81UeRTknZqzlENY3dFsaVtYUATv9meKfp806Ab4BVmFdNCVba+GPEMqJ57Sw2fG5ncLrn/AHniLJ0UU
|
TDR_AVATAR_STORAGE_PATH=encrypted:BEL2fezcXf+tr0K63V+5EHWxNbjCNH3puOv+TiDA3fmvmQyTBtnHkMwJQK/utubqxjtV3oDRMW2Pzyzrui1aD444K+qqe+Ok4xtTLqyKQLwdnq1z5rj1GiVmkRF9yTjMLlOK1xCTG+gNgPvak0llMhj0
|
||||||
TDR_GAME_ICON_STORAGE_PATH=encrypted:BAv4kKj8sR1An7Ym9+hLCIu4xSQdC4fc5dyRy+WaY00kmKLQ7Pj4pO9qc4Vzx4nNYp8124WevhHQeB6CzCoK94owCsmX/HHoEhajcoEGFSOaferE61bJfufK1pbzb7SRsQbZQjCh3yqFlTooYs1x/QHBZ5WW
|
TDR_GAME_ICON_STORAGE_PATH=encrypted:BCMiSAwFOWy6zKZMyY5QA4fkVgTHEZ8JSDwpk8832G5iarY1btuwsy2Kk6lr43dqsg9eurzknouYOmyleTYun5yCHx+P+1PfUzE01JMBMd20cKj5yuMrO+qNA7KK3+Zib72NswGVvFhC8fKruSAzAkSeIdRo
|
||||||
|
|
||||||
APP_ORIGIN=encrypted:BGs9cTUlSzmPJhHESxefXKdzZ9w8l192He08Sl3IR4NXcNUlUuvxrDh0qEJXYX19GHtO1XkB95oXefO6Rw1H8SnZZ5zeeCHbOn+7oW/7v8dGLWkQMGTlG8YyJ6LNVAD1GBISAxjrO3e5lEAUOm/j0rvc8u24tg==
|
APP_ORIGIN=encrypted:BKVQPCrdHEjAt7SrT0Y/gtFveCKIp70LjbvCuv1HOTjYalHEPP0enrtiS4IYqbtHsV2mVQZ71y1k458WTJoP0LbezQ/9msoMuQGIop4EbtbCnuXZERtimGxZvIst0/JA4dfJUBx8Knsy+sZc1QaKmzBrbzQo3w==
|
||||||
PASSKEY_RP_ID=encrypted:BI7ozl7Nw3NR3TlJ4TycJxt0HuNu7sQwNWFldib/sU+F6L60bNgENK60E9JXfx0euz7DwtOCFGeecKCtH7Vb7vIedT6aNyqXOtjmEi4zrEP12oIJJvd1nzU/2f2uBx6Hp+YwOyvhnMxGMg==
|
PASSKEY_RP_ID=encrypted:BJxMVNfCOL9kErsgvPGSl3oYI9SyWVaP8gSQWggm9nVmLebxuHhQrdaewPDUED3Wduele51zZLvbUt80BFxZtdLFpQ3UwCWz9F12likanFmTdcJ7ILP7J5ITFD9GNNiUrQyKptG8qbZxUA==
|
||||||
|
|
||||||
DISCORD_CLIENT_ID="encrypted:BIcLXUW7R3kKKwL+vPqzDzdaF9FFc/o2LLW12za5X04VROQrPgt0nf+fChDvAE9zwd/iUcBGJO0kpYuYHPPxXzjrCRwa64oj+gxKuUtjqWDuJeos5ABYzG2ZPF9M4Lbtdiq7D/PAn4/vGXhKjUxhf+M5wTo="
|
DISCORD_CLIENT_ID="encrypted:BPUCg9hzJyK2glzRIfXi2Ok1B9MUPIVNK7kOVRzfKHEmmsBFpQBYm7a2Qzbrjrg6j72KoMvw8oR3DnZEM4CUmNiYhjmoKyERyutV0BZ0+WuWgezykEFM0xFE/ERl/XTWytLm1/972Z272k/0bhambG5qvhg="
|
||||||
DISCORD_CLIENT_SECRET="encrypted:BGANQ+E6ASvqsdW4Xvyh1hluWRQsyjtXJFJWma3pY00Ge9+kHZD7z9O9kWJKNKFGNoJP3G1bnGk1CT7x0g82R5X5jLx8YF8EETI3KjCybIctTwAGq+GepFpz7FuQIPhL3fNSteuF0eCC7kGTxTW2zrPyaLIDErF4+KCdDKRgoct/"
|
DISCORD_CLIENT_SECRET="encrypted:BGKAsGe/w5tvZDN/qWU6qHly1NDxugwkyqg+nBwf+5GtvaY30mhfymevELebjYXAfykHiJ5uTOUDW0GBWHVhEzvAAjCARQFnKfDE6PQeJv21wzAszOzS/YcikbeIvwjHavGqFDddvwJa2YvIjw+duh4b/CcrWMOUgP78IB2w+mYS"
|
||||||
DISCORD_REDIRECT_URI=encrypted:BIVlGoCrhTUQE6e8j0Ol7oi/5DMHblTRfWiexZg24VZrD2t7gomiZn1ylMo0YYHNm2B7jf+TkRTlNg36wUukhkl5L+qQRGaVYuhiD57s8Hzv9Z+8zcREoqKu8jTtA8CKTYHFmaiwRfYQJAxNzWRCGNp1oAUd1TpXUEZ03hORaIgD6DJAu6OeaipofKs=
|
DISCORD_REDIRECT_URI=encrypted:BNI/hR+kYuIcgBhw3hT3PEDOafhjk13jsixFQhl6XISy75/4yIY1UP3eHBT3ra5Rs5oimIIkeMXHbp5MfMGFKA90vN7YnT9XGus8POGVi1t3+4aNoB06ke1/SIvByb9SDoYQYo4XT7LVf29n0khY9Rub9UbXBr3ljy3yR3Z03wFQFVFEnHg5PeOXZPE=
|
||||||
|
|
||||||
SMTP_HOST="encrypted:BAzR0UdCJHlutOl01cw6kzPRseaot+cr+7ePUSKOcorXrGE4d2BWvQCT5A37qydjieSN98H1lV+9nykNMYUAD3PniY7/nd9DD6bDmXnG1nKACedCCF/fTPgfct0PMSN+FmgJI6UMbP45Q/l/QM3eRBJcqQ=="
|
SMTP_HOST="encrypted:BIxKDxHcN3/bwHQVILQMP5FakAD6ixciFWK0kYfdSZl3YX1BVK8+KAPxNS2LXd83wI8swM+f8TJpaVYsE/teALY3rA67KgvyZH09nmH0VvuKmdA5jYia08m/ZXb69a1W5cWMEkzG/l+ERGG7jJD38aLKlA=="
|
||||||
SMTP_PORT="encrypted:BEfvu12wUPhrlVp1ICDAE6ohx5rKsUWGcnYnqL40VwafI8nayK9DqU5tuxag5du4E9lkHZ7QOLbn9QRw3sR3Yl74C+PlIktQWSQ5qCWMh2qbjYmulF8uTSWNXpPhIlhCyYVipA=="
|
SMTP_PORT="encrypted:BNzY9VsJiCyPiHD60VI8Lu+QgyA88G5Rxsnxo4Zc2cGiPdTjT6u4yjzlZS/m3DqRN3mHtY/C7p1qRRXu1uj/JRku6t7JiaEyZnoYS1cfdS0tpmwTiqhHzVlpBUiX+2ImpRfYOw=="
|
||||||
SMTP_SECURE="encrypted:BHsWBT1w//boBqindVIcD3KOaDcjBfuHb+cm6ph5zP3dMT+oaFlG7msV28uPnbL5ZtOFXC6sUzVicMeqktu9O5ciQ+K9CFM+q+GStQBFqJ1SbfEq0isspV9eXWcTGPmTZAfBDxFx"
|
SMTP_SECURE="encrypted:BGzfmeSU+mRw/YpYAu7svFBzsWVwxjwCrnzQJQ4t6JKGVLTDCv4vdSYZBxO9fBvpJD0QQyyndnHxHAvlENMy2AEv2WaB2SICf5T5AUwvENH9kPkX5h/M43Diji3n30qh/Gm53pGo"
|
||||||
SMTP_USER="encrypted:BL5lI1+d91LAlbLyGCZ343Uqq55McVdF7Jo+e33EauaChfg+ltKB2d4lFGm7KHkQ+K9CorxlPpR82YjssS7ixYWwbEkFL7PgOgWxF8ksKQHDL8UR1zaqTmyylKseVfj/YIquaA7nS5FsjzpWCDVQFETJNw=="
|
SMTP_USER="encrypted:BHR+Buh73QAfXrLWEJ0ipXStkQJuVhSdK6F5AW/9/XXcvvhd0WZnJXXIVg4niP6VkW6R+/IoW7jzkUR7VUez7Z44sOFWIfe2M3uxrEDCeu/zNmhZaV5YCuBXj/pcEVb3/eUAiMgI1NhUez2/MCw29gV1FA=="
|
||||||
SMTP_PASSWORD="encrypted:BNsDbxMZqE142HZ8PyEytL9mG7L2y79rMvM6aqDcs0ONNYtjM2ffDwKbu4ZySwkv6NS6mdKtHTVg3/tGmvdPRjbuK4f6FpRsJEnQvCwLaN0Gy9ikeCMOwrqQCOyfJSTkNTBiOvrz5e/Id0BXYjOkShk="
|
SMTP_PASSWORD="encrypted:BJksY/oTv8MWWnN/I0YkEjhSLK94TqX0LrnukecdgSOLQoRydqB52gBD7iUwqQkiDs70LCsSsDROZnJvLgfI/OHWTpIr69t2cPCjAcQjBwsRFXAt5hmaTeAOAVlSPleuCNRqxHOHJckiH/alSB/9yPM="
|
||||||
SMTP_FROM="encrypted:BDsOlqmQFyAGOOJ2f+h9AS+CmV3uxEpy1mJaNzWIGFI1NMumy9s+dFgvKrunhAIImDSzod5BphAAOtUW8RyrIEO5wJvB1IJ+qoAw06hSWa4qWu1thdSu2ZEP3D+4ll7kpsOcMEU5PVFlSHQRLsPRdOS5mSGCy5VkXb2zhNAQ0A=="
|
SMTP_FROM="encrypted:BAuwcaQj+CpkOO3teZdpbkVP2P/5DffxIm4WRlkPAS6uw1jlLkPKOwrdNMms6w+QqvugvL44J9ScrK5bSfNryUBaupwY+hKisqi4zzEPcwEqmychyMP8O/m65lQsBYI9oH/BLruNns8jXBTtNBkMcY95hwAqL3FQHhAdGN7wdQ=="
|
||||||
|
|
||||||
ORIGIN="encrypted:BHOwfV6ahbNXg8A7HNbApjaDrQiwVXSI5f/tBx/eeFYdBxgQJ8zQQKUOzSeICdOtx1z9BxIOWHr86+E8ZG67k0RxSeevpSLWe8bINOy+7Xf9IzXBgg2aOpY0gWxAcXercLKvP+UNJJy3TuEl9sQZqRJBjNPyhyJB"
|
ORIGIN="encrypted:BGcNYe6p2UIQF0clcq7riYodr67GX27F9tHDU4b3I85c2rXyE5c9igAQxZgZs5YXDIAt9FG3lHt/QsXYNFzSruwBE+7cXHMN8i6vGxDaxFepMjhDsLhDd911BnPZH0ENuc5GrtS6aAKXRj5kGz42eXh+Dpj2JqCI"
|
||||||
SCHEDULER_TOKEN="encrypted:BOQwJdKcGoxWA70HS/7Rpv6uQiupCw+QuY54At7zuDZFopdUCOj+lu2N9I8L+497bROGBsWG2knGzu4NXvb264PS6c5kk3UGrcctAPMalN4Cb9mAm/ZHeC0rvTkF0Lk50/2sesc9Zmi8Qess8+hSRdyAttt8V7YoT6b4kDsX6sK1ws+s6cd+IG7Oxfc="
|
SCHEDULER_TOKEN="encrypted:BK3ud4X3Vsou3bURV/0iaK7Tye06UK8UJJy6i6rUsMJi7QcfFjuHYgNdupFCn6DRO6u0TzlpFSa87tnWQeKUaK+SRb+nxSrdGoejY3S5abqlOMxKTUyCGvoYQbECbto2PXasxEb4iDcpIi5ZPIupVk9CHz/jCmZDAlxp53bOyIQtXEMUdx0Er0VgFU4="
|
||||||
|
REMOVE_BG_API_KEY=encrypted:BJg92nPiZcW+3jPgds3uVIIvGGOJDhabNeHTdReragLV0S9AKfuktG3DG5pq7QSVLh3ZIsGOTgrFkp35nlIYX2KxYhFivpccmZ5AQSYoEHEKiBJkBMPwwkS+PXMAVEW8LWMg9ieapwBnd3z5UNuj+OH9T72gNBahWg==
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-
|
|||||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-revert','audit.revert','Attempt confirmed compensating reversals of supported ISAC audit events');
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-revert','audit.revert','Attempt confirmed compensating reversals of supported ISAC audit events');
|
||||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-metrics-view','metrics.view','View read-only application, runtime, and database health metrics');
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-metrics-view','metrics.view','View read-only application, runtime, and database health metrics');
|
||||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-system-notifications-manage','system.notifications.manage','View and edit automated ISAC system notification messages');
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-system-notifications-manage','system.notifications.manage','View and edit automated ISAC system notification messages');
|
||||||
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-images-manage','images.manage','View, upload, rename, tag, and categorize managed image assets');
|
||||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-avatars-review','avatars.review','Review and remove custom user avatars');
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-avatars-review','avatars.review','Review and remove custom user avatars');
|
||||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-isac-access');
|
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-isac-access');
|
||||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-manage');
|
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-manage');
|
||||||
@@ -783,3 +784,21 @@ CREATE TABLE IF NOT EXISTS game_data_icons (
|
|||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY(entity_type, entity_id)
|
PRIMARY KEY(entity_type, entity_id)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS managed_images (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
is_icon INTEGER NOT NULL DEFAULT 0 CHECK(is_icon IN (0,1)),
|
||||||
|
force_white INTEGER NOT NULL DEFAULT 0 CHECK(force_white IN (0,1)),
|
||||||
|
storage_key TEXT NOT NULL UNIQUE,
|
||||||
|
original_filename TEXT NOT NULL,
|
||||||
|
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
||||||
|
width INTEGER NOT NULL CHECK(width > 0),
|
||||||
|
height INTEGER NOT NULL CHECK(height > 0),
|
||||||
|
created_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS managed_images_name_nocase_idx ON managed_images(name COLLATE NOCASE);
|
||||||
|
CREATE INDEX IF NOT EXISTS managed_images_category_idx ON managed_images(category);
|
||||||
|
|||||||
@@ -272,3 +272,58 @@ The workflow exports a consistent production snapshot, restarts production,
|
|||||||
then stops staging and imports it. The staging importer creates both full and
|
then stops staging and imports it. The staging importer creates both full and
|
||||||
ISAC-only safety backups before replacement. This is intentionally manual and
|
ISAC-only safety backups before replacement. This is intentionally manual and
|
||||||
never runs as part of either deployment.
|
never runs as part of either deployment.
|
||||||
|
|
||||||
|
### Promote managed images without replacing other data
|
||||||
|
|
||||||
|
Image Library assets use stable IDs and can be promoted additively from local
|
||||||
|
development. Export a bundle after finishing local uploads and metadata edits:
|
||||||
|
|
||||||
|
Set the server-only `REMOVE_BG_API_KEY` in each encrypted environment that
|
||||||
|
accepts ISAC uploads. It is sent only from the server in the `X-Api-Key` header.
|
||||||
|
Icon uploads that already contain transparency do not call remove.bg; opaque
|
||||||
|
icon sources use its preview output and then continue through local trimming,
|
||||||
|
white normalization, WebP conversion, and storage.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run images:export
|
||||||
|
```
|
||||||
|
|
||||||
|
The command writes a timestamped JSON bundle under `data/backups/`. It contains
|
||||||
|
only managed-image metadata and processed WebP files. Transfer that bundle to
|
||||||
|
the deployment host, stop the target `app` and `scheduler` services so the
|
||||||
|
embedded database is unlocked, and run the merge using the target image:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Staging
|
||||||
|
cd /opt/shd-build-stage
|
||||||
|
docker compose -f compose.staging.yml stop scheduler app
|
||||||
|
docker compose -f compose.staging.yml run --rm \
|
||||||
|
-v /secure/path:/import:ro app \
|
||||||
|
node scripts/managed-images-import.mjs /import/managed-images.json --confirm-merge
|
||||||
|
docker compose -f compose.staging.yml up -d app scheduler
|
||||||
|
|
||||||
|
# Production
|
||||||
|
cd /opt/shd-build-production
|
||||||
|
docker compose -f compose.production.yml stop scheduler app
|
||||||
|
docker compose -f compose.production.yml run --rm \
|
||||||
|
-v /secure/path:/import:ro app \
|
||||||
|
node scripts/managed-images-import.mjs /import/managed-images.json --confirm-merge
|
||||||
|
docker compose -f compose.production.yml up -d app scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
Each import creates a full target database backup before changing anything.
|
||||||
|
New stable IDs are inserted, identical existing assets are skipped, and a
|
||||||
|
different existing record blocks the import. After reviewing an intentional
|
||||||
|
metadata or file replacement, add `--update-existing`. Imported changes append
|
||||||
|
`image.imported` or `image.import_updated` audit events. Remove the transferred
|
||||||
|
bundle after both environments have been verified because it may contain
|
||||||
|
unpublished visual assets and creator identifiers.
|
||||||
|
|
||||||
|
The `.env.staging` and `.env.production` npm import commands are intended for
|
||||||
|
maintenance performed directly in an environment where those database and
|
||||||
|
storage paths are mounted:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run images:import:staging -- /path/to/bundle.json --confirm-merge
|
||||||
|
npm run images:import:production -- /path/to/bundle.json --confirm-merge
|
||||||
|
```
|
||||||
|
|||||||
@@ -1564,6 +1564,31 @@ navigation regions. Search fields have visible or accessible labels, keyboard
|
|||||||
focus uses Signal Orange, and collapsed state must remain understandable
|
focus uses Signal Orange, and collapsed state must remain understandable
|
||||||
without motion.
|
without motion.
|
||||||
|
|
||||||
|
### Managed image library
|
||||||
|
|
||||||
|
Reusable uploaded visuals belong in the ISAC Image Library under System
|
||||||
|
Configuration. Present assets as a dense responsive registry of preview cards:
|
||||||
|
the artwork is primary, while name, assignment category, tags, dimensions, and
|
||||||
|
file size remain compact operational metadata. A checkerboard preview surface
|
||||||
|
may make transparency legible without changing the asset.
|
||||||
|
|
||||||
|
Image intake uses the shared picker, drop-zone, and clipboard control. The
|
||||||
|
`Normalize as icon` option trims transparent outer margins and standardizes the
|
||||||
|
canvas. When the source has no usable transparency, the server requests
|
||||||
|
preview-resolution background removal before local normalization and reports
|
||||||
|
configuration, credit, rate-limit, foreground-detection, or network failures
|
||||||
|
with a specific recovery. Its dependent `Force white` option converts visible pixels to the
|
||||||
|
canonical neutral icon color while preserving alpha. Both controls state their
|
||||||
|
processing consequence in text, expose checked and disabled states, and remain
|
||||||
|
keyboard accessible. Metadata edits preserve the stable asset identity, close
|
||||||
|
after a successful save, and use the shared toast and editor lifecycle.
|
||||||
|
|
||||||
|
Use controlled assignment categories for broad placement and free-form,
|
||||||
|
normalized tags for discovery. Names are unique and human-readable. Search
|
||||||
|
covers names, categories, and tags. On narrow screens, processing controls
|
||||||
|
stack vertically and cards reduce to one column so previews and actions retain
|
||||||
|
usable sizes.
|
||||||
|
|
||||||
### ISAC mutation consequence
|
### ISAC mutation consequence
|
||||||
|
|
||||||
Every writable game-data module states what Save will do before the operator
|
Every writable game-data module states what Save will do before the operator
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
"isac:export": "dotenvx run -f .env.development -- node scripts/isac-data-export.mjs",
|
"isac:export": "dotenvx run -f .env.development -- node scripts/isac-data-export.mjs",
|
||||||
"isac:import": "dotenvx run -f .env.development -- node scripts/isac-data-import.mjs",
|
"isac:import": "dotenvx run -f .env.development -- node scripts/isac-data-import.mjs",
|
||||||
"isac:export:staging": "dotenvx run -f .env.staging -- node scripts/isac-data-export.mjs",
|
"isac:export:staging": "dotenvx run -f .env.staging -- node scripts/isac-data-export.mjs",
|
||||||
|
"images:export": "dotenvx run -f .env.development -- node scripts/managed-images-export.mjs",
|
||||||
|
"images:import": "dotenvx run -f .env.development -- node scripts/managed-images-import.mjs",
|
||||||
|
"images:import:staging": "dotenvx run -f .env.staging -- node scripts/managed-images-import.mjs",
|
||||||
|
"images:import:production": "dotenvx run -f .env.production -- node scripts/managed-images-import.mjs",
|
||||||
"stage:bootstrap:export": "dotenvx run -f .env.development -- node scripts/stage-bootstrap-export.mjs",
|
"stage:bootstrap:export": "dotenvx run -f .env.development -- node scripts/stage-bootstrap-export.mjs",
|
||||||
"production:bootstrap:export": "dotenvx run -f .env.development -- node scripts/environment-bootstrap-export.mjs production",
|
"production:bootstrap:export": "dotenvx run -f .env.development -- node scripts/environment-bootstrap-export.mjs production",
|
||||||
"notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs",
|
"notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs",
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ try {
|
|||||||
};
|
};
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
||||||
console.log(`${target} bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} game-data icons.`);
|
console.log(`${target} bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} managed image assets.`);
|
||||||
console.log(`Bootstrap bundle: ${outputPath}`);
|
console.log(`Bootstrap bundle: ${outputPath}`);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(temporaryBackupPath, { force: true });
|
await rm(temporaryBackupPath, { force: true });
|
||||||
|
|||||||
@@ -111,5 +111,5 @@ for (const file of bundle.files.gameIcons) {
|
|||||||
await writeFile(join(gameIconDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 });
|
await writeFile(join(gameIconDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Bootstrapped ${target} with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} game-data icons.`);
|
console.log(`Bootstrapped ${target} with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} managed image assets.`);
|
||||||
console.log(`Source bundle: ${bundlePath}`);
|
console.log(`Source bundle: ${bundlePath}`);
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ for (const name of ISAC_DATA_TABLES) {
|
|||||||
|
|
||||||
const icons = [];
|
const icons = [];
|
||||||
const iconTable = tables.find((table) => table.name === 'game_data_icons');
|
const iconTable = tables.find((table) => table.name === 'game_data_icons');
|
||||||
for (const row of iconTable?.rows ?? []) {
|
const managedImageTable = tables.find((table) => table.name === 'managed_images');
|
||||||
|
for (const row of [...(iconTable?.rows ?? []), ...(managedImageTable?.rows ?? [])]) {
|
||||||
if (typeof row.storage_key !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) {
|
if (typeof row.storage_key !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) {
|
||||||
throw new Error(`Invalid game-data icon storage key "${String(row.storage_key)}".`);
|
throw new Error(`Invalid managed asset storage key "${String(row.storage_key)}".`);
|
||||||
}
|
}
|
||||||
const content = await readFile(join(iconDirectory, row.storage_key));
|
const content = await readFile(join(iconDirectory, row.storage_key));
|
||||||
icons.push({ storageKey: row.storage_key, contentBase64: content.toString('base64') });
|
icons.push({ storageKey: row.storage_key, contentBase64: content.toString('base64') });
|
||||||
@@ -44,5 +45,5 @@ const snapshot = {
|
|||||||
await mkdir(dirname(outputPath), { recursive: true });
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
||||||
|
|
||||||
console.log(`Exported ${totalRows} ISAC rows and ${icons.length} uploaded icons.`);
|
console.log(`Exported ${totalRows} ISAC rows and ${icons.length} uploaded image assets.`);
|
||||||
console.log(`ISAC snapshot: ${outputPath}`);
|
console.log(`ISAC snapshot: ${outputPath}`);
|
||||||
|
|||||||
@@ -74,6 +74,6 @@ for (const icon of snapshot.icons) {
|
|||||||
await writeFile(join(iconDirectory, icon.storageKey), Buffer.from(icon.contentBase64, 'base64'), { mode: 0o600 });
|
await writeFile(join(iconDirectory, icon.storageKey), Buffer.from(icon.contentBase64, 'base64'), { mode: 0o600 });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Imported ${importedRows} ISAC rows and ${snapshot.icons.length} uploaded icons from ${snapshotPath}.`);
|
console.log(`Imported ${importedRows} ISAC rows and ${snapshot.icons.length} uploaded image assets from ${snapshotPath}.`);
|
||||||
console.log(`Full pre-import backup: ${backupPath}`);
|
console.log(`Full pre-import backup: ${backupPath}`);
|
||||||
console.log(`ISAC pre-import snapshot: ${isacBackupPath}`);
|
console.log(`ISAC pre-import snapshot: ${isacBackupPath}`);
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export const ISAC_DATA_TABLES = [
|
|||||||
'gear_mod_quality_values',
|
'gear_mod_quality_values',
|
||||||
'gear_mod_secondary_options',
|
'gear_mod_secondary_options',
|
||||||
'gear_mod_secondary_quality_values',
|
'gear_mod_secondary_quality_values',
|
||||||
'game_data_icons'
|
'game_data_icons',
|
||||||
|
'managed_images'
|
||||||
];
|
];
|
||||||
|
|
||||||
export const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
|
export const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { connect } from '@tursodatabase/database';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
||||||
|
const storageDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
||||||
|
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const outputPath = resolve(process.argv[2] || `./data/backups/managed-images-${timestamp}.json`);
|
||||||
|
const database = await connect(databasePath);
|
||||||
|
const rows = await (await database.prepare('SELECT * FROM managed_images ORDER BY created_at')).all();
|
||||||
|
const images = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (typeof row.storage_key !== 'string' || !/^library-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) {
|
||||||
|
throw new Error(`Invalid managed-image storage key "${String(row.storage_key)}".`);
|
||||||
|
}
|
||||||
|
images.push({ row, contentBase64: (await readFile(join(storageDirectory, row.storage_key))).toString('base64') });
|
||||||
|
}
|
||||||
|
const bundle = {
|
||||||
|
format: 'shd-build-managed-images',
|
||||||
|
version: 1,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
sourceDatabase: basename(databasePath),
|
||||||
|
images
|
||||||
|
};
|
||||||
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
||||||
|
console.log(`Exported ${images.length} managed image assets.`);
|
||||||
|
console.log(`Image bundle: ${outputPath}`);
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { connect } from '@tursodatabase/database';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
const bundleArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--'));
|
||||||
|
if (!bundleArgument || !process.argv.includes('--confirm-merge')) {
|
||||||
|
console.error('Usage: node scripts/managed-images-import.mjs <bundle.json> --confirm-merge [--update-existing]');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const updateExisting = process.argv.includes('--update-existing');
|
||||||
|
const bundlePath = resolve(bundleArgument);
|
||||||
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||||
|
if (bundle?.format !== 'shd-build-managed-images' || bundle?.version !== 1 || !Array.isArray(bundle.images)) {
|
||||||
|
throw new Error('The selected file is not a supported SHD.build managed-image bundle.');
|
||||||
|
}
|
||||||
|
for (const image of bundle.images) {
|
||||||
|
if (typeof image?.row?.id !== 'string' || !/^[0-9a-f-]{36}$/i.test(image.row.id)
|
||||||
|
|| typeof image.row.storage_key !== 'string' || !/^library-[0-9a-f-]{36}\.webp$/i.test(image.row.storage_key)
|
||||||
|
|| typeof image.contentBase64 !== 'string') {
|
||||||
|
throw new Error('The bundle contains an invalid managed image.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const backup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
||||||
|
cwd: process.cwd(), stdio: 'inherit', env: process.env
|
||||||
|
});
|
||||||
|
if (backup.error) throw backup.error;
|
||||||
|
if (backup.status !== 0) throw new Error('Safety backup failed; no managed images were imported.');
|
||||||
|
const assetBackup = spawnSync(process.execPath, ['scripts/isac-data-export.mjs'], {
|
||||||
|
cwd: process.cwd(), stdio: 'inherit', env: process.env
|
||||||
|
});
|
||||||
|
if (assetBackup.error) throw assetBackup.error;
|
||||||
|
if (assetBackup.status !== 0) throw new Error('Uploaded-asset safety snapshot failed; no managed images were imported.');
|
||||||
|
|
||||||
|
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
||||||
|
const storageDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
||||||
|
const database = await connect(databasePath);
|
||||||
|
await mkdir(storageDirectory, { recursive: true });
|
||||||
|
let inserted = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let unchanged = 0;
|
||||||
|
|
||||||
|
const preflight = [];
|
||||||
|
for (const image of bundle.images) {
|
||||||
|
const row = image.row;
|
||||||
|
const existing = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(row.id);
|
||||||
|
if (existing && !updateExisting) {
|
||||||
|
const same = ['name','category','tags_json','is_icon','force_white','storage_key','byte_size','width','height']
|
||||||
|
.every((key) => String(existing[key]) === String(row[key]));
|
||||||
|
if (!same) throw new Error(`Image ${row.id} already exists with different metadata. Re-run with --update-existing after review.`);
|
||||||
|
unchanged++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const creator = await (await database.prepare('SELECT id FROM users WHERE id=?')).get(row.created_by);
|
||||||
|
if (!creator) throw new Error(`Image "${row.name}" references a creator that does not exist in the target database.`);
|
||||||
|
const nameConflict = await (await database.prepare('SELECT id FROM managed_images WHERE name=? COLLATE NOCASE AND id<>?')).get(row.name, row.id);
|
||||||
|
if (nameConflict) throw new Error(`Image name "${row.name}" is already assigned to a different target asset.`);
|
||||||
|
preflight.push({ image, existing });
|
||||||
|
}
|
||||||
|
|
||||||
|
await database.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const { image, existing } of preflight) {
|
||||||
|
const row = image.row;
|
||||||
|
await writeFile(join(storageDirectory, row.storage_key), Buffer.from(image.contentBase64, 'base64'), {
|
||||||
|
mode: 0o600, flag: existing ? 'w' : 'wx'
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
await (await database.prepare(`UPDATE managed_images SET
|
||||||
|
name=?,category=?,tags_json=?,is_icon=?,force_white=?,storage_key=?,original_filename=?,
|
||||||
|
byte_size=?,width=?,height=?,updated_at=? WHERE id=?`)).run(
|
||||||
|
row.name,row.category,row.tags_json,row.is_icon,row.force_white,row.storage_key,row.original_filename,
|
||||||
|
row.byte_size,row.width,row.height,row.updated_at,row.id
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
await (await database.prepare(`INSERT INTO managed_images
|
||||||
|
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,created_by,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||||
|
row.id,row.name,row.category,row.tags_json,row.is_icon,row.force_white,row.storage_key,row.original_filename,
|
||||||
|
row.byte_size,row.width,row.height,row.created_by,row.created_at,row.updated_at
|
||||||
|
);
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
await (await database.prepare(`INSERT INTO audit_log
|
||||||
|
(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)`)).run(
|
||||||
|
randomUUID(), row.created_by, 'system_configuration', row.id,
|
||||||
|
existing ? 'image.import_updated' : 'image.imported',
|
||||||
|
existing ? JSON.stringify(existing) : null, JSON.stringify(row), new Date().toISOString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.log(`Managed-image merge complete: ${inserted} inserted, ${updated} updated, ${unchanged} unchanged.`);
|
||||||
@@ -792,3 +792,4 @@
|
|||||||
.dashboard-row>.table-action:active{transform:none}
|
.dashboard-row>.table-action:active{transform:none}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-library-editor{margin:10px 0}.image-library-form{display:grid;grid-template-columns:minmax(330px,.8fr) minmax(440px,1.2fr);gap:12px;margin-top:12px}.image-library-upload .image-drop-input{height:100%;min-height:168px}.image-library-upload .image-upload-preview{width:112px;height:112px}.image-library-metadata{display:grid;grid-template-columns:1fr 1fr;align-content:start;gap:8px}.image-library-metadata .field{margin:0}.image-library-metadata .full,.image-processing-options{grid-column:1/-1}.image-processing-options{display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--shd-border);background:#0d1011}.image-processing-options label{min-height:58px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:9px;padding:9px;cursor:pointer}.image-processing-options label+label{border-left:1px solid var(--shd-border)}.image-processing-options input{width:auto;margin:0;accent-color:var(--shd-orange)}.image-processing-options span{display:grid;gap:3px}.image-processing-options b{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.image-processing-options small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.image-processing-options label:has(input:checked){background:#1b170e;box-shadow:inset 0 -2px var(--shd-orange)}.image-processing-options label.disabled{opacity:.42;cursor:not-allowed}.image-metadata-edit{display:grid;grid-template-columns:72px 1fr 1fr 1.4fr;align-items:end;gap:9px;margin-top:10px}.image-metadata-edit>img{width:72px;height:72px;object-fit:contain;padding:5px;border:1px solid var(--shd-border);background:#090b0c}.image-metadata-edit .field{margin:0}.image-library-grid{display:grid;grid-template-columns:repeat(4,minmax(190px,1fr));gap:8px;margin-top:8px}.image-library-grid article{min-width:0;border:1px solid var(--shd-border);border-top:2px solid #4a5152;background:var(--shd-surface);transition:var(--shd-transition)}.image-library-grid article.icon{border-top-color:var(--shd-orange)}.image-library-grid article:hover{border-color:#596061;background:#15191a}.image-library-preview{height:150px;display:grid;place-items:center;padding:14px;border-bottom:1px solid var(--shd-border);background:linear-gradient(135deg,#0a0c0d 25%,#101314 25%,#101314 50%,#0a0c0d 50%,#0a0c0d 75%,#101314 75%);background-size:16px 16px}.image-library-preview img{display:block;max-width:100%;max-height:100%;object-fit:contain}.image-library-grid header{position:static;height:auto;min-height:54px;display:flex;align-items:center;justify-content:space-between;padding:9px 11px;border-bottom:1px solid var(--shd-border);background:transparent}.image-library-grid header>div{min-width:0;display:grid;gap:3px}.image-library-grid header strong{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.image-library-grid header span,.image-library-grid header b{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.image-library-grid header span{color:var(--shd-text-muted)}.image-library-grid header b{color:var(--shd-orange)}.image-library-tags{min-height:42px;display:flex;align-content:flex-start;flex-wrap:wrap;gap:4px;padding:8px 10px}.image-library-tags span{width:max-content;padding:3px 5px;border:1px solid #3c4243;color:#92999b;font-size:var(--shd-type-telemetry)}.image-library-tags small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry)}.image-library-grid footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 10px;border-top:1px solid var(--shd-border)}.image-library-grid footer span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.image-library-grid footer button{border:0;background:transparent;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}.image-library-empty{grid-column:1/-1;min-height:180px;display:grid;place-content:center;gap:6px;border:1px dashed var(--shd-border);color:var(--shd-text-muted);text-align:center}.image-library-empty strong{color:var(--shd-text)}@media(max-width:1100px){.image-library-grid{grid-template-columns:repeat(3,minmax(180px,1fr))}}@media(max-width:800px){.image-library-form{grid-template-columns:1fr}.image-library-grid{grid-template-columns:repeat(2,minmax(150px,1fr))}.image-metadata-edit{grid-template-columns:72px 1fr}.image-metadata-edit .field:last-child{grid-column:1/-1}}@media(max-width:560px){.image-library-metadata,.image-processing-options,.image-library-grid{grid-template-columns:1fr}.image-library-metadata .full,.image-processing-options{grid-column:auto}.image-processing-options label+label{border-left:0;border-top:1px solid var(--shd-border)}.image-library-preview{height:132px}.image-metadata-edit{grid-template-columns:1fr}.image-metadata-edit>img{width:100%;height:96px}}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<fieldset class="icon-upload-field">
|
<fieldset class="icon-upload-field">
|
||||||
<legend>{label}</legend>
|
<legend>{label}</legend>
|
||||||
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif" {currentUrl} placeholderUrl="/missing-item-icon.svg" {resetToken} emptyLabel="ICON AWAITING CONTRIBUTION" browseLabel={currentUrl ? 'REPLACE ICON' : 'ADD ICON'}/>
|
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif,image/jpeg" {currentUrl} placeholderUrl="/missing-item-icon.svg" {resetToken} emptyLabel="ICON AWAITING CONTRIBUTION" browseLabel={currentUrl ? 'REPLACE ICON' : 'ADD ICON'}/>
|
||||||
{#if currentUrl}<label class="icon-remove"><input name="removeIcon" type="checkbox" value="1"/><span>REMOVE CURRENT ICON</span></label>{/if}
|
{#if currentUrl}<label class="icon-remove"><input name="removeIcon" type="checkbox" value="1"/><span>REMOVE CURRENT ICON</span></label>{/if}
|
||||||
<small>OPTIONAL · MAY BE CONTRIBUTED LATER · TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX</small>
|
<small>OPTIONAL · SVG, PNG, WEBP, AVIF, OR JPEG · OPAQUE BACKGROUNDS REMOVED WHEN NEEDED · 2 MiB MAX</small>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Database } from '@tursodatabase/database';
|
|||||||
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
|
||||||
|
|
||||||
export const GAME_ICON_TYPES = ['talent', 'brand_set', 'os_protocol', 'skill_chip'] as const;
|
export const GAME_ICON_TYPES = ['talent', 'brand_set', 'os_protocol', 'skill_chip'] as const;
|
||||||
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
|
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
|
||||||
@@ -42,7 +43,7 @@ export async function processGameDataIconUpload(file: File): Promise<Buffer> {
|
|||||||
if (!file.size || file.size > MAX_GAME_ICON_BYTES) {
|
if (!file.size || file.size > MAX_GAME_ICON_BYTES) {
|
||||||
throw new GameDataIconValidationError('Choose a transparent icon no larger than 2 MiB.');
|
throw new GameDataIconValidationError('Choose a transparent icon no larger than 2 MiB.');
|
||||||
}
|
}
|
||||||
const input = Buffer.from(await file.arrayBuffer());
|
let input = Buffer.from(await file.arrayBuffer());
|
||||||
try {
|
try {
|
||||||
const looksLikeSvg = file.type === 'image/svg+xml' || input.subarray(0, 512).toString('utf8').toLowerCase().includes('<svg');
|
const looksLikeSvg = file.type === 'image/svg+xml' || input.subarray(0, 512).toString('utf8').toLowerCase().includes('<svg');
|
||||||
if (looksLikeSvg) {
|
if (looksLikeSvg) {
|
||||||
@@ -55,17 +56,28 @@ export async function processGameDataIconUpload(file: File): Promise<Buffer> {
|
|||||||
throw new GameDataIconValidationError('SVG icons cannot contain scripts, embedded images, or external resources.');
|
throw new GameDataIconValidationError('SVG icons cannot contain scripts, embedded images, or external resources.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
|
let image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
|
||||||
const metadata = await image.metadata();
|
let metadata = await image.metadata();
|
||||||
if (!metadata.format || !['png', 'webp', 'avif', 'svg'].includes(metadata.format) || !metadata.hasAlpha) {
|
if (!metadata.format || !['jpeg', 'png', 'webp', 'avif', 'svg'].includes(metadata.format)) {
|
||||||
throw new GameDataIconValidationError('Use an SVG, PNG, WebP, or AVIF icon with a transparent background.');
|
throw new GameDataIconValidationError('Use a JPEG, SVG, PNG, WebP, or AVIF icon.');
|
||||||
}
|
}
|
||||||
const stats = await image.stats();
|
let stats = await image.stats();
|
||||||
if (stats.isOpaque) {
|
if (!metadata.hasAlpha || stats.isOpaque) {
|
||||||
throw new GameDataIconValidationError('The icon must contain transparent background pixels around the artwork.');
|
input = await removeBackgroundPreview(input);
|
||||||
|
image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
|
||||||
|
metadata = await image.metadata();
|
||||||
|
stats = await image.stats();
|
||||||
|
}
|
||||||
|
if (!metadata.hasAlpha || stats.isOpaque) {
|
||||||
|
throw new GameDataIconValidationError('The processed icon must contain transparent background pixels.');
|
||||||
}
|
}
|
||||||
return await image
|
return await image
|
||||||
.rotate()
|
.rotate()
|
||||||
|
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 })
|
||||||
|
.extend({
|
||||||
|
top: 2, right: 2, bottom: 2, left: 2,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||||
|
})
|
||||||
.resize(GAME_ICON_SIZE, GAME_ICON_SIZE, {
|
.resize(GAME_ICON_SIZE, GAME_ICON_SIZE, {
|
||||||
fit: 'contain',
|
fit: 'contain',
|
||||||
position: 'centre',
|
position: 'centre',
|
||||||
@@ -75,6 +87,7 @@ export async function processGameDataIconUpload(file: File): Promise<Buffer> {
|
|||||||
.toBuffer();
|
.toBuffer();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof GameDataIconValidationError) throw error;
|
if (error instanceof GameDataIconValidationError) throw error;
|
||||||
|
if (error instanceof BackgroundRemovalError) throw new GameDataIconValidationError(error.message);
|
||||||
throw new GameDataIconValidationError('That icon could not be processed. Use a transparent SVG, PNG, WebP, or AVIF file.');
|
throw new GameDataIconValidationError('That icon could not be processed. Use a transparent SVG, PNG, WebP, or AVIF file.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { Database } from '@tursodatabase/database';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
|
||||||
|
|
||||||
|
export const MANAGED_IMAGE_CATEGORIES = [
|
||||||
|
'General', 'Interface', 'Gear', 'Weapons', 'Weapon Mods', 'Gear Mods',
|
||||||
|
'Talents', 'Brands', 'OS Protocols', 'Skill Chips', 'System'
|
||||||
|
] as const;
|
||||||
|
export const MAX_MANAGED_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||||
|
const MAX_PIXELS = 24_000_000;
|
||||||
|
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||||
|
const KEY_PATTERN = /^library-[0-9a-f-]{36}\.webp$/i;
|
||||||
|
|
||||||
|
export class ManagedImageValidationError extends Error {}
|
||||||
|
|
||||||
|
export function managedImageStorageDirectory() {
|
||||||
|
return path.resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || path.join(process.cwd(), 'data', 'game-icons'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const storagePath = (key: string) => {
|
||||||
|
if (!KEY_PATTERN.test(key)) throw new Error('Invalid managed image storage key');
|
||||||
|
return path.join(managedImageStorageDirectory(), key);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function managedImageUrl(id: string, updatedAt?: unknown) {
|
||||||
|
if (!ID_PATTERN.test(id)) return '';
|
||||||
|
const version = typeof updatedAt === 'string' ? `?v=${encodeURIComponent(updatedAt)}` : '';
|
||||||
|
return `/images/library/${id}${version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSvg(input: Buffer) {
|
||||||
|
const source = input.toString('utf8');
|
||||||
|
if (/<(?:script|foreignObject|image|iframe|object|embed)\b/i.test(source)
|
||||||
|
|| /<!ENTITY\b/i.test(source)
|
||||||
|
|| /\b(?:href|xlink:href)\s*=\s*["'](?!#)/i.test(source)
|
||||||
|
|| /@import\b/i.test(source)
|
||||||
|
|| /url\(\s*["']?(?!#)/i.test(source)) {
|
||||||
|
throw new ManagedImageValidationError('SVG files cannot contain scripts, embedded images, or external resources.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processManagedImage(file: File, icon: boolean, forceWhite: boolean) {
|
||||||
|
if (!file.size || file.size > MAX_MANAGED_IMAGE_BYTES) {
|
||||||
|
throw new ManagedImageValidationError('Choose an image no larger than 5 MiB.');
|
||||||
|
}
|
||||||
|
const input = Buffer.from(await file.arrayBuffer());
|
||||||
|
try {
|
||||||
|
const looksLikeSvg = file.type === 'image/svg+xml' || input.subarray(0, 512).toString('utf8').toLowerCase().includes('<svg');
|
||||||
|
if (looksLikeSvg) validateSvg(input);
|
||||||
|
let processedInput = input;
|
||||||
|
let source = sharp(processedInput, { limitInputPixels: MAX_PIXELS, failOn: 'warning' });
|
||||||
|
let metadata = await source.metadata();
|
||||||
|
if (!metadata.format || !['jpeg', 'png', 'webp', 'avif', 'svg'].includes(metadata.format)) {
|
||||||
|
throw new ManagedImageValidationError('Use a JPEG, SVG, PNG, WebP, or AVIF image.');
|
||||||
|
}
|
||||||
|
const sourceStats = await source.stats();
|
||||||
|
if (icon && (!metadata.hasAlpha || sourceStats.isOpaque)) {
|
||||||
|
processedInput = await removeBackgroundPreview(processedInput);
|
||||||
|
source = sharp(processedInput, { limitInputPixels: MAX_PIXELS, failOn: 'warning' });
|
||||||
|
metadata = await source.metadata();
|
||||||
|
}
|
||||||
|
let pipeline = source.rotate();
|
||||||
|
if (metadata.hasAlpha) {
|
||||||
|
pipeline = pipeline.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 });
|
||||||
|
}
|
||||||
|
pipeline = pipeline.resize(icon ? 256 : 1600, icon ? 256 : 1600, {
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: !icon
|
||||||
|
});
|
||||||
|
if (icon && forceWhite) {
|
||||||
|
const normalized = await pipeline
|
||||||
|
.resize(256, 256, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.ensureAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
for (let offset = 0; offset < normalized.data.length; offset += normalized.info.channels) {
|
||||||
|
normalized.data[offset] = 255;
|
||||||
|
normalized.data[offset + 1] = 255;
|
||||||
|
normalized.data[offset + 2] = 255;
|
||||||
|
}
|
||||||
|
pipeline = sharp(normalized.data, {
|
||||||
|
raw: { width: normalized.info.width, height: normalized.info.height, channels: normalized.info.channels }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const output = await pipeline.webp({ quality: icon ? 90 : 84, alphaQuality: 100, effort: 5 }).toBuffer();
|
||||||
|
const result = await sharp(output).metadata();
|
||||||
|
return { output, width: result.width ?? 0, height: result.height ?? 0 };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ManagedImageValidationError) throw error;
|
||||||
|
if (error instanceof BackgroundRemovalError) throw new ManagedImageValidationError(error.message);
|
||||||
|
throw new ManagedImageValidationError('That image could not be processed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveManagedImageFile(id: string, output: Buffer) {
|
||||||
|
if (!ID_PATTERN.test(id)) throw new Error('Invalid managed image identifier');
|
||||||
|
const key = `library-${id}.webp`;
|
||||||
|
await mkdir(managedImageStorageDirectory(), { recursive: true });
|
||||||
|
await writeFile(storagePath(key), output, { flag: 'wx', mode: 0o600 });
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readManagedImage(database: Database, id: string) {
|
||||||
|
if (!ID_PATTERN.test(id)) return null;
|
||||||
|
const row = await (await database.prepare('SELECT storage_key FROM managed_images WHERE id=?')).get(id);
|
||||||
|
if (typeof row?.storage_key !== 'string') return null;
|
||||||
|
try {
|
||||||
|
return await readFile(storagePath(row.storage_key));
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const REMOVE_BG_ENDPOINT = 'https://api.remove.bg/v1.0/removebg';
|
||||||
|
const REMOVE_BG_PREVIEW_EDGE = 1500;
|
||||||
|
|
||||||
|
export class BackgroundRemovalError extends Error {}
|
||||||
|
|
||||||
|
function apiMessage(status: number) {
|
||||||
|
if (status === 400) return 'remove.bg could not identify a removable foreground in that image.';
|
||||||
|
if (status === 402) return 'remove.bg preview credits are currently unavailable.';
|
||||||
|
if (status === 403) return 'The remove.bg API key was rejected. Update the server configuration.';
|
||||||
|
if (status === 429) return 'remove.bg is temporarily rate limited. Wait briefly and try again.';
|
||||||
|
return 'Background removal is temporarily unavailable. Try again or upload an image with transparency.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeBackgroundPreview(input: Buffer): Promise<Buffer<ArrayBuffer>> {
|
||||||
|
const apiKey = process.env.REMOVE_BG_API_KEY?.trim();
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new BackgroundRemovalError(
|
||||||
|
'This icon needs background removal, but remove.bg is not configured. Add REMOVE_BG_API_KEY or upload a transparent image.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const prepared = await sharp(input, { failOn: 'warning' })
|
||||||
|
.rotate()
|
||||||
|
.resize(REMOVE_BG_PREVIEW_EDGE, REMOVE_BG_PREVIEW_EDGE, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||||
|
.toBuffer();
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('size', 'preview');
|
||||||
|
form.set('format', 'png');
|
||||||
|
form.set('image_file', new File([new Uint8Array(prepared)], 'icon-source.png', { type: 'image/png' }));
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(REMOVE_BG_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Api-Key': apiKey },
|
||||||
|
body: form,
|
||||||
|
signal: AbortSignal.timeout(45_000)
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new BackgroundRemovalError('remove.bg could not be reached. Try again or upload a transparent image.');
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new BackgroundRemovalError(apiMessage(response.status));
|
||||||
|
const output = Buffer.from(await response.arrayBuffer());
|
||||||
|
const metadata = await sharp(output, { failOn: 'warning' }).metadata();
|
||||||
|
if (!metadata.hasAlpha) throw new BackgroundRemovalError('remove.bg returned an image without transparency.');
|
||||||
|
return output;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { getDatabase } from '$lib/server/database';
|
||||||
|
import { readManagedImage } from '$lib/server/managed-images';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const image = await readManagedImage(await getDatabase(), params.id);
|
||||||
|
if (!image) error(404, 'Image not found');
|
||||||
|
return new Response(new Uint8Array(image), {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'image/webp',
|
||||||
|
'cache-control': 'public, max-age=31536000, immutable',
|
||||||
|
'x-content-type-options': 'nosniff'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
];
|
];
|
||||||
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
|
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
|
||||||
const systemSections = [
|
const systemSections = [
|
||||||
|
{ href: '/isac/images', label: 'Image library', permission: 'images.manage' },
|
||||||
{ href: '/isac/system-messages', label: 'Automated messages', permission: 'system.notifications.manage' },
|
{ href: '/isac/system-messages', label: 'Automated messages', permission: 'system.notifications.manage' },
|
||||||
{ href: '/isac/metrics', label: 'System metrics', permission: 'metrics.view' }
|
{ href: '/isac/metrics', label: 'System metrics', permission: 'metrics.view' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
|||||||
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
|
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
|
||||||
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/configuration');
|
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/configuration');
|
||||||
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
|
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
|
||||||
|
if (permissions.includes('images.manage')) redirect(303, '/isac/images');
|
||||||
if (permissions.includes('system.notifications.manage')) redirect(303, '/isac/system-messages');
|
if (permissions.includes('system.notifications.manage')) redirect(303, '/isac/system-messages');
|
||||||
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
|
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
|
||||||
redirect(303, '/account');
|
redirect(303, '/account');
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
|
import { requirePermission } from '$lib/server/auth/authorization';
|
||||||
|
import { getDatabase } from '$lib/server/database';
|
||||||
|
import { recordIsacAudit } from '$lib/server/audit';
|
||||||
|
import {
|
||||||
|
MANAGED_IMAGE_CATEGORIES,
|
||||||
|
ManagedImageValidationError,
|
||||||
|
managedImageUrl,
|
||||||
|
processManagedImage,
|
||||||
|
saveManagedImageFile
|
||||||
|
} from '$lib/server/managed-images';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
const normalizeTags = (value: unknown) => [...new Set(String(value ?? '')
|
||||||
|
.split(',').map((tag) => tag.normalize('NFKC').trim().toLocaleLowerCase()).filter(Boolean))]
|
||||||
|
.slice(0, 20);
|
||||||
|
|
||||||
|
const readMetadata = (form: FormData) => {
|
||||||
|
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||||
|
const category = String(form.get('category') ?? '').trim();
|
||||||
|
const tags = normalizeTags(form.get('tags'));
|
||||||
|
if (name.length < 2 || name.length > 80) return { error: 'Name must be 2–80 characters.' } as const;
|
||||||
|
if (!MANAGED_IMAGE_CATEGORIES.includes(category as (typeof MANAGED_IMAGE_CATEGORIES)[number])) return { error: 'Choose a valid assignment category.' } as const;
|
||||||
|
if (tags.some((tag) => tag.length > 30)) return { error: 'Tags must be 30 characters or fewer.' } as const;
|
||||||
|
return { name, category, tags } as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
|
await requirePermission(await getSessionUser(cookies), 'images.manage');
|
||||||
|
const database = await getDatabase();
|
||||||
|
const query = url.searchParams.get('q')?.trim().slice(0, 80) ?? '';
|
||||||
|
const requestedPerPage = Number(url.searchParams.get('perPage') ?? 25);
|
||||||
|
const perPage = [10, 25, 50, 100].includes(requestedPerPage) ? requestedPerPage : 25;
|
||||||
|
const requestedPage = Math.max(1, Number(url.searchParams.get('page') ?? 1) || 1);
|
||||||
|
const where = query ? `WHERE name LIKE ? ESCAPE '\\' COLLATE NOCASE OR category LIKE ? ESCAPE '\\' COLLATE NOCASE OR tags_json LIKE ? ESCAPE '\\' COLLATE NOCASE` : '';
|
||||||
|
const escaped = `%${query.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||||
|
const args = query ? [escaped, escaped, escaped] : [];
|
||||||
|
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM managed_images ${where}`)).get(...args))?.count ?? 0);
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / perPage));
|
||||||
|
const page = Math.min(requestedPage, pageCount);
|
||||||
|
const images = await (await database.prepare(`SELECT managed_images.*, users.username AS creator_username
|
||||||
|
FROM managed_images JOIN users ON users.id=managed_images.created_by ${where}
|
||||||
|
ORDER BY managed_images.updated_at DESC LIMIT ? OFFSET ?`)).all(...args, perPage, (page - 1) * perPage);
|
||||||
|
const stats = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||||
|
COALESCE(SUM(is_icon),0) AS icons, COALESCE(SUM(byte_size),0) AS bytes,
|
||||||
|
COUNT(DISTINCT category) AS categories FROM managed_images`)).get();
|
||||||
|
return {
|
||||||
|
categories: MANAGED_IMAGE_CATEGORIES,
|
||||||
|
images: images.map((image) => ({
|
||||||
|
...image,
|
||||||
|
tags: JSON.parse(String(image.tags_json)),
|
||||||
|
url: managedImageUrl(String(image.id), image.updated_at)
|
||||||
|
})),
|
||||||
|
stats,
|
||||||
|
search: query,
|
||||||
|
pagination: { page, perPage, total, pageCount }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
upload: async ({ cookies, request }) => {
|
||||||
|
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
|
||||||
|
const form = await request.formData();
|
||||||
|
const metadata = readMetadata(form);
|
||||||
|
if ('error' in metadata) return fail(400, { imageError: metadata.error });
|
||||||
|
const file = form.get('image');
|
||||||
|
if (!(file instanceof File) || !file.size) return fail(400, { imageError: 'Choose an image to upload.' });
|
||||||
|
const isIcon = form.get('isIcon') === 'on';
|
||||||
|
const forceWhite = isIcon && form.get('forceWhite') === 'on';
|
||||||
|
try {
|
||||||
|
const processed = await processManagedImage(file, isIcon, forceWhite);
|
||||||
|
const id = randomUUID();
|
||||||
|
const storageKey = await saveManagedImageFile(id, processed.output);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const after = {
|
||||||
|
id, ...metadata, is_icon: isIcon ? 1 : 0, force_white: forceWhite ? 1 : 0,
|
||||||
|
storage_key: storageKey, original_filename: file.name, byte_size: processed.output.length,
|
||||||
|
width: processed.width, height: processed.height, created_by: operator.id,
|
||||||
|
created_at: now, updated_at: now
|
||||||
|
};
|
||||||
|
const database = await getDatabase();
|
||||||
|
await (await database.prepare(`INSERT INTO managed_images
|
||||||
|
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,created_by,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||||
|
id, metadata.name, metadata.category, JSON.stringify(metadata.tags), isIcon ? 1 : 0,
|
||||||
|
forceWhite ? 1 : 0, storageKey, file.name, processed.output.length,
|
||||||
|
processed.width, processed.height, operator.id, now, now
|
||||||
|
);
|
||||||
|
await recordIsacAudit({
|
||||||
|
actorId: operator.id, category: 'system_configuration',
|
||||||
|
action: 'image.created', entityId: id, before: null, after
|
||||||
|
});
|
||||||
|
return { imageSaved: true, editorMode: 'add', resultToken: randomUUID() };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ManagedImageValidationError) return fail(400, { imageError: error.message });
|
||||||
|
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: async ({ cookies, request }) => {
|
||||||
|
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
|
||||||
|
const form = await request.formData();
|
||||||
|
const id = String(form.get('id') ?? '');
|
||||||
|
const metadata = readMetadata(form);
|
||||||
|
if ('error' in metadata) return fail(400, { imageError: metadata.error });
|
||||||
|
const database = await getDatabase();
|
||||||
|
const before = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(id);
|
||||||
|
if (!before) return fail(404, { imageError: 'Image not found.' });
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
await (await database.prepare('UPDATE managed_images SET name=?,category=?,tags_json=?,updated_at=? WHERE id=?'))
|
||||||
|
.run(metadata.name, metadata.category, JSON.stringify(metadata.tags), updatedAt, id);
|
||||||
|
const after = { ...before, name: metadata.name, category: metadata.category, tags_json: JSON.stringify(metadata.tags), updated_at: updatedAt };
|
||||||
|
await recordIsacAudit({
|
||||||
|
actorId: operator.id, category: 'system_configuration',
|
||||||
|
action: 'image.updated', entityId: id, before, after
|
||||||
|
});
|
||||||
|
return { imageSaved: true, editorMode: 'edit', resultToken: randomUUID() };
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
|
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
|
||||||
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
|
let { data, form } = $props();
|
||||||
|
let adding = $state(false);
|
||||||
|
let editing = $state<any>(null);
|
||||||
|
let isIcon = $state(true);
|
||||||
|
let resetToken = $state(0);
|
||||||
|
let consumedToken = $state<string | null>(null);
|
||||||
|
const size = (bytes: unknown) => {
|
||||||
|
const value = Number(bytes ?? 0);
|
||||||
|
return value < 1024 * 1024 ? `${(value / 1024).toFixed(0)} KiB` : `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||||
|
};
|
||||||
|
$effect(() => {
|
||||||
|
if (form?.imageSaved && form?.resultToken && form.resultToken !== consumedToken) {
|
||||||
|
consumedToken = form.resultToken;
|
||||||
|
if (form.editorMode === 'edit') editing = null;
|
||||||
|
else {
|
||||||
|
adding = true;
|
||||||
|
isIcon = true;
|
||||||
|
resetToken += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Image library — ISAC</title></svelte:head>
|
||||||
|
<div class="module-toolbar">
|
||||||
|
<div><p class="eyebrow">SYSTEM CONFIGURATION</p><h2>IMAGE LIBRARY</h2><p>Process, classify, and locate reusable SHD.build visual assets.</p></div>
|
||||||
|
<div><button class="primary" type="button" onclick={() => { adding = true; editing = null; }}>UPLOAD IMAGE +</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="module-kpis">
|
||||||
|
<div><span>ASSETS</span><strong>{Number(data.stats.total)}</strong><small>Managed files</small></div>
|
||||||
|
<div><span>ICONS</span><strong>{Number(data.stats.icons)}</strong><small>Normalized assets</small></div>
|
||||||
|
<div><span>CATEGORIES</span><strong>{Number(data.stats.categories)}</strong><small>Assignments in use</small></div>
|
||||||
|
<div><span>STORAGE</span><strong>{size(data.stats.bytes)}</strong><small>Processed WebP</small></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if adding}
|
||||||
|
<section class="horizontal-editor image-library-editor">
|
||||||
|
<div class="editor-title"><div><p class="eyebrow">NEW MANAGED ASSET</p><h3>UPLOAD + PROCESS</h3></div><button type="button" aria-label="Cancel image upload" onclick={() => adding = false}>×</button></div>
|
||||||
|
<PendingForm action="?/upload" enctype="multipart/form-data" successMessage="Image added to the library.">
|
||||||
|
<div class="image-library-form">
|
||||||
|
<div class="image-library-upload"><ImageUploadInput name="image" accept="image/svg+xml,image/png,image/webp,image/avif,image/jpeg" required {resetToken} emptyLabel="SELECT IMAGE"/></div>
|
||||||
|
<div class="image-library-metadata">
|
||||||
|
<label class="field">ASSET NAME<input name="name" maxlength="80" required placeholder="e.g. Weapon critical hit"/></label>
|
||||||
|
<label class="field">ASSIGNMENT CATEGORY<select name="category" required>{#each data.categories as category}<option>{category}</option>{/each}</select></label>
|
||||||
|
<label class="field full">TAGS · COMMA SEPARATED<input name="tags" maxlength="620" placeholder="critical, weapon, offensive"/></label>
|
||||||
|
<div class="image-processing-options">
|
||||||
|
<label><input name="isIcon" type="checkbox" bind:checked={isIcon}/><span><b>NORMALIZE AS ICON</b><small>Remove an opaque background when needed, trim margins, and fit within 256 × 256.</small></span></label>
|
||||||
|
<label class:disabled={!isIcon}><input name="forceWhite" type="checkbox" checked disabled={!isIcon}/><span><b>FORCE WHITE</b><small>Replace visible icon color while preserving transparency.</small></span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if form?.imageError}<p class="error">{form.imageError}</p>{/if}
|
||||||
|
<div class="form-actions"><button class="secondary" type="button" onclick={() => adding = false}>CANCEL</button><button class="primary">PROCESS + SAVE →</button></div>
|
||||||
|
</PendingForm>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editing}
|
||||||
|
<section class="horizontal-editor image-library-editor compact">
|
||||||
|
<div class="editor-title"><div><p class="eyebrow">EDIT LIBRARY METADATA</p><h3>{editing.name}</h3></div><button type="button" aria-label="Cancel image editing" onclick={() => editing = null}>×</button></div>
|
||||||
|
<PendingForm action="?/update" successMessage="Image metadata updated.">
|
||||||
|
<input type="hidden" name="id" value={editing.id}/>
|
||||||
|
<div class="image-metadata-edit">
|
||||||
|
<img src={editing.url} alt=""/>
|
||||||
|
<label class="field">ASSET NAME<input name="name" value={editing.name} maxlength="80" required/></label>
|
||||||
|
<label class="field">ASSIGNMENT CATEGORY<select name="category">{#each data.categories as category}<option selected={category === editing.category}>{category}</option>{/each}</select></label>
|
||||||
|
<label class="field">TAGS<input name="tags" value={editing.tags.join(', ')} maxlength="620"/></label>
|
||||||
|
</div>
|
||||||
|
{#if form?.imageError}<p class="error">{form.imageError}</p>{/if}
|
||||||
|
<div class="form-actions"><button class="secondary" type="button" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE CHANGES →</button></div>
|
||||||
|
</PendingForm>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<RegistryPagination pagination={data.pagination} search={data.search} searchPlaceholder="SEARCH NAMES, TAGS, OR CATEGORIES"/>
|
||||||
|
<section class="image-library-grid" aria-label="Managed image library">
|
||||||
|
{#each data.images as image}
|
||||||
|
<article class:icon={image.is_icon}>
|
||||||
|
<div class="image-library-preview"><img src={image.url} alt="Preview of {image.name}"/></div>
|
||||||
|
<header><div><strong>{image.name}</strong><span>{image.category}</span></div><b>{image.is_icon ? 'ICON' : 'IMAGE'}</b></header>
|
||||||
|
<div class="image-library-tags">{#each image.tags as tag}<span>{tag}</span>{/each}{#if !image.tags.length}<small>NO TAGS</small>{/if}</div>
|
||||||
|
<footer><span>{image.width} × {image.height} · {size(image.byte_size)}</span><button class="table-action" type="button" onclick={() => { editing = image; adding = false; }}>EDIT →</button></footer>
|
||||||
|
</article>
|
||||||
|
{:else}
|
||||||
|
<div class="image-library-empty"><strong>NO MATCHING ASSETS</strong><span>Upload the first image or revise the registry search.</span></div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
Reference in New Issue
Block a user