From 63b04d26a98a2cebc91be3cd4fbdc3f8fa6a0792 Mon Sep 17 00:00:00 2001 From: JB Date: Sun, 26 Jul 2026 13:46:44 -0400 Subject: [PATCH] feat: add managed ISAC image library --- .env.development | 25 ++--- .env.example | 1 + .env.production | 25 ++--- .env.staging | 33 +++--- database/schema.sql | 19 ++++ docs/DEPLOYMENT.md | 55 ++++++++++ docs/DESIGN_SYSTEM.md | 25 +++++ package.json | 4 + scripts/environment-bootstrap-export.mjs | 2 +- scripts/environment-bootstrap-import.mjs | 2 +- scripts/isac-data-export.mjs | 7 +- scripts/isac-data-import.mjs | 2 +- scripts/isac-data-tables.mjs | 3 +- scripts/managed-images-export.mjs | 28 +++++ scripts/managed-images-import.mjs | 99 +++++++++++++++++ src/app.css | 1 + src/lib/components/IconUploadField.svelte | 4 +- src/lib/server/game-data-icons.ts | 29 +++-- src/lib/server/managed-images.ts | 115 ++++++++++++++++++++ src/lib/server/remove-background.ts | 48 +++++++++ src/routes/images/library/[id]/+server.ts | 16 +++ src/routes/isac/+layout.svelte | 1 + src/routes/isac/+page.server.ts | 1 + src/routes/isac/images/+page.server.ts | 126 ++++++++++++++++++++++ src/routes/isac/images/+page.svelte | 91 ++++++++++++++++ 25 files changed, 705 insertions(+), 57 deletions(-) create mode 100644 scripts/managed-images-export.mjs create mode 100644 scripts/managed-images-import.mjs create mode 100644 src/lib/server/managed-images.ts create mode 100644 src/lib/server/remove-background.ts create mode 100644 src/routes/images/library/[id]/+server.ts create mode 100644 src/routes/isac/images/+page.server.ts create mode 100644 src/routes/isac/images/+page.svelte diff --git a/.env.development b/.env.development index 8ab7a65..094b954 100644 --- a/.env.development +++ b/.env.development @@ -5,21 +5,22 @@ DOTENV_PUBLIC_KEY_DEVELOPMENT="038fa4f9f7abd799fabebc69cb4d6a42a2ea361c3ec799580ddd213bcafac5c167" # .env.development -TDR_DATABASE_PATH=encrypted:BNTRek+Cz9NI9Qasv0a+BWUNs5u5qTESE+1U7fybMS3NJLQkY8WR/eQ0v8/ZoiiHLSM/EGum5NRNC/iRSYIjkjFTvfC4xFnUXALvy3zM2Iw4AezGWdmOZ6RI5csNtGQKQ0idOicuA1YN3FpcDdOSSZXTOyVniwFduWcAZTuBPTo= -DISCORD_CLIENT_ID=encrypted:BDuk9O2rhgVwi6/NqnG1+n2Jeb/A6ciQwNAdY3Ih7R08DhTF4g2d9ZKpg+OzmIL7BeZF3ikkedlfljxn8xbW8f9YGrrfaLLdTDVClyT+scyfcKeaK6uunQVvipG5UwhotEd3VS8T9IcQyNDVq9y9SEgIjyo= -DISCORD_CLIENT_SECRET=encrypted:BE3+3NfbuQpjbpc8Pyl0e5YEW478dOKdi5cWuwHjJ5L2z/X+1hJ+B6ag4oXwPBt20t12qWtv5IYPJGMb+Uzk6hZLazqkHxl+DUozeNMAOOrNHESPiTwH7fpI0vf4oVTWH+j4iYMRUlmLkjzOICTmAL+ljOJgi5dTqHx05unXGqxZ -DISCORD_REDIRECT_URI=encrypted:BEWUNH2YlygQ23TBiXj9lgJ7hijU13YJhiBYVM1VEHHM3s8ryqWJ+hz2bJ0EyGHg3XU9UBPDhmgLkafULYzY/KgOLmSMq6TkrYq1e7Qec+K70ZyqoNp7L1s9C0EK//dpkE1iMvd92bhLPOeS8sOhFN4dyYbK2vPGRdi7AKxFwiU/jq7pbwkHeU5DQXU= +TDR_DATABASE_PATH=encrypted:BDdEUO0HX3ZgfPHXCtNAjISsKdb3IZlOGDZE2vSmAGm4Y2+UgLAoOX8Ij+CL/GJUD0IVU0HUsTyEx6CrXutXEd1uWz7dxqgtra59jmBKasN6ucrgaY/8ktRq/uoTm/t99xyuEWRXJdZMFSk7YkbkQuHgvoU5K3nqI2ml0mEFeL4= +DISCORD_CLIENT_ID=encrypted:BA9XZlnAHz/glIBFqEbROTvLmgW1ZzONZ+ZPVHyw6hQB7U6YxnYfdmQ+HJnLkgkJyib6PFc+AijW11xgd5B8Z3BvIsXjzjs29fFs4yYnEVbxvoBD2KB2XJHtqabdVIYq9dFjfG/zY1hb3CIi8FSgMVFxxBw= +DISCORD_CLIENT_SECRET=encrypted:BHhFIIU5v7Ljz9LtAR3z6Y6qAswOtYDKtcvXW4EXAs85OiZ2sjfQ1VNoBtkg2wtevEmTvxLoyq1EEBcSIAmAIUeBaJPZa60FNK0nU7y/GaUvqV+Q8G3V7AkRmoptiOZ4jduiPzlzs3+jNvJmrf6KEnYWKx4ytEQpADO75BgDhoCS +DISCORD_REDIRECT_URI=encrypted:BL1YDjAhLKcVVwiBUzjkHt9wLuM6bZJXl1RnZO0DWanGa39LjiQ3ybJFN0I2jNVgcVjqk3nSiCYDGOQt49UeF08aMdJkOa67SVR9yDtESJtMKlPJxL/HaLf8eAex4DlO/R7NlwDRUua5pRBcu0TH6UeC7FphlYNGRFPwfcuso32Hg+Fl5f1CKU4tUTQ= # Passwordless authentication -APP_ORIGIN=encrypted:BE7igk/cyE/Wv72Rc8A0O815n/uDyyaKdLwdonSjrc7rHWiwBifF8S6EQ5tUkH8i0eQsxLGxcAJTW0H9gfGw0Nftxl+1Mr2slGLZJxxiJ4Upp1CU3pLwIgcTg82ew8ewOvfYnT6mVLPMHO/02vomjOxYobLKdQ== -PASSKEY_RP_ID=encrypted:BNMQdCdZR9e47vjPfme83ujNlJqsmJgGeri8r+uuJ1MwHeWXuv8FKi+XHKKX8GaXvEdqD0mLSFyYvUMgrzJb/ZwEo/B77opQ67AXFtERz4qUKDc6XdOvFNRUvlPjPaO/eoyqr64WIEjG3w== +APP_ORIGIN=encrypted:BNe3AfHVmq+afhm6TwmoT+SDn+oUfz0S0NgP7rjh+o0mHizYiq0/pInyIVarq0Z7WTwlhJIW0SCE72DMoQvO2mIEdnO4jOxXOZywTpcMHpWZ8fBgVNC5wlH+KYnBmH5MWg0fjrPALXNpTAyYGoIiB1WiuiDrpA== +PASSKEY_RP_ID=encrypted:BMDp5uRV/HQ6BaqEOODDXYrrukSRNMVrUzr56yoMUkJwIogCT+SmedS/pXJGvi7er8PvJGSfC1SoX8G+YiUFua/dcSERXFVGRg+p8LywPZDN/YuEfv5p6GuRgQmd/rcPvP5TUqhC0SEPIw== # Shared SMTP account for magic-link and email-verification delivery. # Implicit TLS/SSL normally uses port 465. For STARTTLS on port 587, set # SMTP_SECURE=false. -SMTP_HOST=encrypted:BGk17DA/BpNINXIl4d7LKTLi3IfAjvyk1OhP5yPnk6ZjE3FHSJl3ed6tQh5oPoUt8zovm+hyV2rD/Pae+axnWdxKXtUohp5HkqNqC1T4SEeVWpUsx5m/xXt8hxcwEQ/1HfzTcdx41wo/hR2ereZMFaX7sA== -SMTP_PORT=encrypted:BBzjlvppArJeJNKCeNyuf4+bX4ohICRycdb/BbZTryN114iZ1d6wyQr6Gre2Eg9fhTQqdIiDjjqpb9UWbjavutlHFZ0UarMUWaOBRfVd9+sqmXI8/vmA6Scp8z4MxteqB5+6jQ== -SMTP_SECURE=encrypted:BBMBvqEuiHmFAy+C56yr/g1kPQsVHu+xO5eVtHis4vdAHHsGVO/blfVMHDw0PAiHPFITHDrp7GjNnvQSq0AKgLK+Mn2/Iv7XC9oQedoplQFOY4kNuM77qWJur/H93DgtMckkfNB+ -SMTP_USER=encrypted:BHmiGy4eBiIOP5PLYBsqomWDCc7DSFvmfXPyPtD6/Cj0KLQAWaVVQowQbsc0XuvuOhesEAcrMBtAD0+rsAmAhoHvdudqc7jPXk0IAFeeasAp8CWvPzd2Afb6tI/Z2xv5BxVsR6l4p1uh+SlK0lfskBGSdQ== -SMTP_PASSWORD=encrypted:BJ8mNgq9j9mmsptaEP8L47MbuB/l9jmkMGXco5ybw91a/vZFyZ5xLWTUxdVYYlHiBIAhjs1eEsx7dQgc4+5wmGFyNfDILod5Turp/OqDD8qV2c7wsKGzOrV6TCGELscoxiHg28YCZCriZnIs9Uw9yRE= -SMTP_FROM=encrypted:BGOoIMcTZ8Wknq81asUOHBtHsqKmUeVq8SDdvp+F5lWKwWxSos8cE/inhJk8gfVEp8cf6m8xVZQt+qUh0lscKA5LPpyixnFe1AyHRAlfqc+r2200492HxHrwKZfEzZ9RFMlN7omgC8ADAZ6eo5mvT/udqHWJpIQLJNSV5GFgjw== +SMTP_HOST=encrypted:BPyC/VDuZ5nvNssrmooa1yMlQ2DJUBTViB+84o+RdmzKaIYtHSw5ZkIAA8uNjDyCGSKtmf/6TCDD2CCz6IOCcLgkT3e6ycYVp1Sa7LCqeLNK3azR1QIRzS9zwEq5txtuzdDK5IvzUNqNdduR0PlZZrJNvQ== +SMTP_PORT=encrypted:BGMe8boYWFP3ZnDGSQYOKBfDne+0ylzmONtpqslJsv6FhrhB7v7Tho7fSdOMvC7/F987J36eKP/dxyje8uWCAA8D/305HiIm4I78nPd9rJ3x1WVLJGO22TZfUJDIdbBF4WR04w== +SMTP_SECURE=encrypted:BJA9tMvTfopV/UTxmrfRt3Xaf73VokM3TPTg4X+NEPjk7C8wrifPcRbFsA0bWz3XWAzHoGTPCICQm2iaPh/KkVlpY0KE2ZUME9u1RirhjngByaK4m+bFfSHg2J9NRffGDTtevMvE +SMTP_USER=encrypted:BKeUeUDV0hfc79vQuUrmMPJbIs2yFzfupaIXjSBlgJzuQtTqX6cQPIUV+4xIoO8pycOrf5esF7YN7JZXdgZFhULPyTW/ufBkMmDffkrH2l5aOtdV6EArXdHzo/V76UNr7/2Kzx5aO+3CcxvKwOHH9/WReQ== +SMTP_PASSWORD=encrypted:BFmv9RUty2dlzHo1wm8bbqDECJG/npi8hciwAu7jzssSCU28hFXPiPeAdTw6thCAlLAm2ECR2z3Oopn1I77wJZDAh1N5JRZ2eybgE205VDLq4NnbqwH1tljDwlyDY9z+6uBGtoANnnW/DR8GPZwAoWw= +SMTP_FROM=encrypted:BMQEkKEg7FbSqbF5LZwUYWMbcFLBXuVoJ5zpGGnCkSyrV7fuA4wXZ7lBjQz+b9iMNmhDxO9n7lsVRARkXQVHWLx3lUTdnfrh3dzphfTqG6TVBaWX+JIkrN+V4MRYyopLPqh4ZfrirZPz1qq6q/1HZcSmSqC6NQF6dBJQBHzCJQ== +REMOVE_BG_API_KEY=encrypted:BLhGrWHuRJsZ0sbBiGpZpoYecsJpw4l+jVnocSJWl9kszTgewHl29FPZXS2FEJUg0NRGVpuNvT6+l9k69pO8vO6UGl4tngNu/bzcZaDGMdTfAlS4Q9KvLQIaYs7DzlE7tlrfaFom9XGCSWoLEDnAhwtglZaz4xMd8A== diff --git a/.env.example b/.env.example index 4761f18..6f0c3f4 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,4 @@ SMTP_FROM=SHD.build # Long random bearer token used only by the internal container scheduler. SCHEDULER_TOKEN= +REMOVE_BG_API_KEY= diff --git a/.env.production b/.env.production index 842dd94..7b66674 100644 --- a/.env.production +++ b/.env.production @@ -5,21 +5,22 @@ DOTENV_PUBLIC_KEY_PRODUCTION="034d799df86cb16852742f1b2a42c69dde86396ddce96448c429c6ba68034a331d" # .env.production -TDR_DATABASE_PATH=encrypted:BE4lCm2/hLx3wEPCPvmIWHEPHS2oVLZHcWS6e4FmRwwkSR7GxaifHgccy32IInzWXiUp8P8OnZS6MYJn5X04wKtWV0y4FfbiCGs0e8YpM1Fo513hd1LmB6uDZo5pkacJV07KpG8NV01RveoHhe/ANN/UVwStFc7eZj85G4cu6A== -DISCORD_CLIENT_ID=encrypted:BK2ZUIggBGYBCU3zEaSOmo2mxqiWnOKtdKxK7FWTw56bG3xFoXtmSZ/vyz+OF/pLd6ciS/WO1nAxC+FND4mLqdZfm99MrWacQj79///zB+vsouDdwg3zqS76dxQ1zES4lPTzk+fhRqHe/EsqQK4sweax4fc= -DISCORD_CLIENT_SECRET=encrypted:BAwObVwDUb5HMWun2WUYpm3zrqFQHegE1KQnfq5I9tglZQXl6/XxkwHtNQFmoU0WgpsJERT1N+yO+0CtBzhdpufIHyXqjCj8uYD70wJvXY2BqEkth0JgKcMi7lO7gSgtybnzUGTDS0xLqjpBgw9lkl0rCun7axH/gkrd/rUEXLdF -DISCORD_REDIRECT_URI=encrypted:BGgy7YS2iU62+mSDlUDNwvZhE39Pr2o2kpnFJCg/16MaQK/VoZVyICSuLBuFE0snAUb8KIvcnklusqao8TU1izHeTS+rvBMLvRVzcyHDTZUynrmn4UCzG5w4phiTVi2OhaZvw8hQSkcvoV57mVtBo7LNEAuVEhuv5WAeOHOX9oaV4qYfKVviRQ== +TDR_DATABASE_PATH=encrypted:BHIvIe9XAoIG5lp+e2weR7nRX3Q6s38O9IqDVFjk9IikerZvrwVBSxDl0g85lrcP36RG69bCjWHxpZxTpbTHdimh+sSbM+b743VM3w++0Z22VSf8vyVM11Mp6KfLG2GwId3ExX2z+ReavxqdczjirHBAvzbGWliDPfP3zey1vQ== +DISCORD_CLIENT_ID=encrypted:BOJFAQzTZp0HWmZzMMHrgHwq6iqu8k7HjLQ/UDD3Z7vMsR8N7denoVb8MhTOw/0Qj9bvJ9AENBiXTBlwY9F0z03xH+4+E6Hh6z0DzDF+GU6crTtNPjOdMIFgWM2NLRZM5pl/z7giHZmk0uBVDfP3H6S1vXc= +DISCORD_CLIENT_SECRET=encrypted:BCa9bbGfHMNn60xvnpiHx93M0t33eqoMR4c5K2J2esrUSnfh4nY/vwdY5JV1vH/G1DH5VG2RJ+v2i0/T7GPMJHD0ErvrS4pB6evBS4BvBFAECwnx2lnO0zC9X8YzmRGrIFjW2zZFp1kINMqCc8t9L3GoHV2jFuLOOxJ0jRWnVVAd +DISCORD_REDIRECT_URI=encrypted:BG+znHavQa2TTpCZh9KCLPAQr3vpqyraygRgfxBXMNym1/9UepTrtHuWGrdymdUHS9xKkpvutuWZW3Nlb9X+359izBfJEW5pXOcUnXNE5FOukFgYnmJVcGU1qglwufmIeJc/qqa5AGvyzzO1CkxkblO91+g7fk/DWWhrsSHlqGZkx5ULyZziiw== # Passwordless authentication -APP_ORIGIN=encrypted:BDJyyjEXFeEjdJiHOGRlxpiWkbzFJBZnM6w1VAb8m4YRW+c0Jvg4kl1XzaRtf63TLjfNWVxxwhCBwJ9s5qeP2+O7FvbgKKxoEXeFyP+gBUDsXss8volhd7toPaF9sjdAtpXY5L0M7SVYOct+FQCHAoyX -PASSKEY_RP_ID=encrypted:BKvPDWDsTEwhAtFAsKENg85D4Klo+Ipp6KnZ0EZ1Nlyi6ikuswRqDFrqYtlleBhzwAHhl7/uD3P6e2cyaNvffokcwQQQb8CXhCVRyGFgy1htvpFXhMmdesMfuqoVZ87wb5ngDlHKKV3E2A== +APP_ORIGIN=encrypted:BFwQW4kaK11NK/UnBLjgwATEcLnAjvR9fFWCmxphBiJHNSYGTBBmpAvhZGvVoA4p/Q9fLP3el+BWgsg2Y3uFajL5oLSMx04UagGm/kX2K1PKztiDyeS1U8qSVQHeVUBtRXpnH7ugbfbLBg9IgXKHUS1L +PASSKEY_RP_ID=encrypted:BPR2a1uPeIaD9KRQAp/6n5h0dE9cDlGMqZtx/asRbey7eeTs2N9ZM5HYvXG2pnIyxO30aD4NrNh7IvJrb52qCXObDdBB0LXn7rv1VYdCQOVxgvpcV+mOb7bECORvm6TGxBTYnf8QwRH+Tw== # Shared SMTP account for magic-link and email-verification delivery. # Implicit TLS/SSL normally uses port 465. For STARTTLS on port 587, set # SMTP_SECURE=false. -SMTP_HOST=encrypted:BNKsoY8sz2Kf1ZaRtE1pU7JQM36Qj+KADK23R+pwVYV3RmeO6OP6AtGVGjDoHoPAqCsoSLPLM0INyX9XFdEJP+DtN0EVVoHgXt4+fAd1kXDBZTwZ+NEgFP6E6h8V5gZdMhar6hrnWecGfrFzs2c/1+e5Kg== -SMTP_PORT=encrypted:BDd5baDM0FWRaEDV/5s94/J8a2Ll9ZxLhBy5NVytdKXPbSq40E9UPKHPNS9vqet69Qceq7rzXgLmcdiqkqp0HdlocJUWzA5YIYcY5Er31o7nau7SKAk0KfnzHA6VVsvRDWxo/w== -SMTP_SECURE=encrypted:BPH6vl2VFiVF0m1sE5Ybt8rrM2TjkgEO5pKnu+5P++fiLkTnEr3bp5tAPbhqWAXpmgmXt2SB2KK/j9hH4Ih0/1cGNvSrqee5FNpwHKncrxrVRzdwSuIgYViziwCrReKXxR+uhzVD -SMTP_USER=encrypted:BKWljaiPijrWfVy5QHxWC83SCzs/MUlzFnI0i+CG19ZSfJ3dUqIdnACqjkpuYI5T/tO1AXB2kOptDc6M7foGdUZ1uDHzTWVOUl5yt0kx6rlAPExuRD9PmOdWvH/CzDQvn6MblJumUtZ5XgNTXG6RgZBqGg== -SMTP_PASSWORD=encrypted:BO10G2D7osxIP5jZP9SQbMFMvcmKJ6Pd4jFzWa2oHnz7RjY5pbRX7r+jFddm0lZqgj6jVJnJiFPMxKSpuSKJUoHKgXL36z5LcmwUuILysyKJAWj5HoeDOGzsgASz0pezESsDoWeH1+0iWPbw6A0AmUA= -SMTP_FROM=encrypted:BJmIwi0QgCDhR6cxkHP5EHsWZznB8ixNaHYxFfWMoVGTgHeTwmrXRDPsXBPXBXUMJkpr6yRAOpeADQjJ4TyqhQVw7R1ghk8nzuESH3/oVxPtpBFYaNkahDHMna+x4ZjsRQFwaisFcLm3RPhPQ8WRfR4VtxUjAnSRyfiXNVflsg== +SMTP_HOST=encrypted:BIjGLiRygyi55OmuLVK8LH0rgUurPZSrM7dom9IG7uE0Cpa9jVoPUek3p3MIyUUyN8SwdDRnr0+WGQsdR47YVnUcRSVVIx1K1HtTre+O6S60QLbPrk7Yv4ET4nl2kmaovPHalp7TpTcZpG1ZcaPy0LQEdQ== +SMTP_PORT=encrypted:BBwsMpX2peGY35Bxv6hFEYlRWouITB7H7HU7/oyA65inKbY0W/JmJIeAng7faJnw6YbUWH9aT8+ztwaBTdtO6VRFclbZcQiJv5MuRHIgHPeydHrH0+Wl5yXaXAiT7iY5itRqug== +SMTP_SECURE=encrypted:BKVehx9fXwB3B0OFnFnIFn5vdMa4Jx2zFtxpkHUNpnxHfJ9zHbI+9RqikZWzwqYV/vKS5KCWsLIIUH2Y+xdodhWeIlvjbYtHB2nkigc7l3xkXJ06iVL/DGWSBytkqZQAz187pW6M +SMTP_USER=encrypted:BLsCJXx0nJBw15JpIqZM+I+EmcfA3yOCr9cAr7msQjsWEYOlRYJQFHQ5mJqwak250QiZl9js+H4NA5idr3HYif1/JkBjcmJyRLc2Xn0ma8IeiwKZqKA0uRM/P0YjQ1+Ez8yO4SYjMEdAoMcAgQldi5K94w== +SMTP_PASSWORD=encrypted:BOXbGNPqBrJpKzSub0j1rDztZOj83JlJSt6blUeqkC7axIGGAjzGbiZeiQ2lrxTADnr0y10LQzz2i4Rw5pj3FsF7sSLko3sHnz7iqK2Foy5ZeSwtX0AgeD3CoV3DyGdl1awl4RhbS45j8JoThzekoqg= +SMTP_FROM=encrypted:BLhAJc0wdRAFRfIHHoq7gvksB2Aus0mV6vVq9BRqznvqHC3OKHHVlCknSD9i95NAkJf4rpaCHahCCu4QIN6Iq94s9+Bj3lnQJuWPt5N67ghegeNeRcHSkq+OomadpJYv6PDDUmJs+Yoc6QkVjWjPVMi4suYZE/V/k17vmGzDZg== +REMOVE_BG_API_KEY=encrypted:BBufDrwuHcLE+i64HX8G0uH2L/MPZF5ZYbLRXNC+EVClzpMnrYpPXpsBJCRVmWJ2f47pH3cEY5dIaTM7JrDhomS/znF54zMwRyszSnlzMcppEEkyN2FicJ/NWR9woOQOGTbtqKdePCMqS3SU+Zq/fNKgvK1Y+COHDQ== diff --git a/.env.staging b/.env.staging index 9cf2544..81a2953 100644 --- a/.env.staging +++ b/.env.staging @@ -6,23 +6,24 @@ DOTENV_PUBLIC_KEY_STAGING="020bab072314925eb9a9cbaf42282e3a0d5c4a8de3d88a51ed92b # .env.staging # Staging is deployed at https://stage.shd.build. -TDR_DATABASE_PATH=encrypted:BEfWarm0yphwbJYuA9UCHZDY4pMcFw1HLHras694Tj2njTVgIuYcJZ2pJEkj8rjWbtFrCGh052GTqf0pX6KmWXTeu0WWgEanSJiRXZ8CYRt8/rif9gk/bqHOq1CUZfG01+eCIqkN1pOEGVAJEsqjkn/EbiWXzcqBtXisBOYdl40= -TDR_AVATAR_STORAGE_PATH=encrypted:BMoxUtKT0LXRmZMrj776svimltQlS7TEhv3ganVjJEbTS+4WlGDHzqxQ91+6nUexyXxeBWW7n81UeRTknZqzlENY3dFsaVtYUATv9meKfp806Ab4BVmFdNCVba+GPEMqJ57Sw2fG5ncLrn/AHniLJ0UU -TDR_GAME_ICON_STORAGE_PATH=encrypted:BAv4kKj8sR1An7Ym9+hLCIu4xSQdC4fc5dyRy+WaY00kmKLQ7Pj4pO9qc4Vzx4nNYp8124WevhHQeB6CzCoK94owCsmX/HHoEhajcoEGFSOaferE61bJfufK1pbzb7SRsQbZQjCh3yqFlTooYs1x/QHBZ5WW +TDR_DATABASE_PATH=encrypted:BLYcX+Rghxqp069PQEWL+9tRPGUfs/ae0D5ga7bNC4Y0llIwGFcd1pq6JqelF/hSkG+3bdy2anahWeU7bY8t6EjVTFobQ0LlQvQbpBwRKwHgDGEwESovjhajDuPv3b62hfuh9fqVdODR3Csu8ndmlq8SiObT3JgyyzljIZqDwMQ= +TDR_AVATAR_STORAGE_PATH=encrypted:BEL2fezcXf+tr0K63V+5EHWxNbjCNH3puOv+TiDA3fmvmQyTBtnHkMwJQK/utubqxjtV3oDRMW2Pzyzrui1aD444K+qqe+Ok4xtTLqyKQLwdnq1z5rj1GiVmkRF9yTjMLlOK1xCTG+gNgPvak0llMhj0 +TDR_GAME_ICON_STORAGE_PATH=encrypted:BCMiSAwFOWy6zKZMyY5QA4fkVgTHEZ8JSDwpk8832G5iarY1btuwsy2Kk6lr43dqsg9eurzknouYOmyleTYun5yCHx+P+1PfUzE01JMBMd20cKj5yuMrO+qNA7KK3+Zib72NswGVvFhC8fKruSAzAkSeIdRo -APP_ORIGIN=encrypted:BGs9cTUlSzmPJhHESxefXKdzZ9w8l192He08Sl3IR4NXcNUlUuvxrDh0qEJXYX19GHtO1XkB95oXefO6Rw1H8SnZZ5zeeCHbOn+7oW/7v8dGLWkQMGTlG8YyJ6LNVAD1GBISAxjrO3e5lEAUOm/j0rvc8u24tg== -PASSKEY_RP_ID=encrypted:BI7ozl7Nw3NR3TlJ4TycJxt0HuNu7sQwNWFldib/sU+F6L60bNgENK60E9JXfx0euz7DwtOCFGeecKCtH7Vb7vIedT6aNyqXOtjmEi4zrEP12oIJJvd1nzU/2f2uBx6Hp+YwOyvhnMxGMg== +APP_ORIGIN=encrypted:BKVQPCrdHEjAt7SrT0Y/gtFveCKIp70LjbvCuv1HOTjYalHEPP0enrtiS4IYqbtHsV2mVQZ71y1k458WTJoP0LbezQ/9msoMuQGIop4EbtbCnuXZERtimGxZvIst0/JA4dfJUBx8Knsy+sZc1QaKmzBrbzQo3w== +PASSKEY_RP_ID=encrypted:BJxMVNfCOL9kErsgvPGSl3oYI9SyWVaP8gSQWggm9nVmLebxuHhQrdaewPDUED3Wduele51zZLvbUt80BFxZtdLFpQ3UwCWz9F12likanFmTdcJ7ILP7J5ITFD9GNNiUrQyKptG8qbZxUA== -DISCORD_CLIENT_ID="encrypted:BIcLXUW7R3kKKwL+vPqzDzdaF9FFc/o2LLW12za5X04VROQrPgt0nf+fChDvAE9zwd/iUcBGJO0kpYuYHPPxXzjrCRwa64oj+gxKuUtjqWDuJeos5ABYzG2ZPF9M4Lbtdiq7D/PAn4/vGXhKjUxhf+M5wTo=" -DISCORD_CLIENT_SECRET="encrypted:BGANQ+E6ASvqsdW4Xvyh1hluWRQsyjtXJFJWma3pY00Ge9+kHZD7z9O9kWJKNKFGNoJP3G1bnGk1CT7x0g82R5X5jLx8YF8EETI3KjCybIctTwAGq+GepFpz7FuQIPhL3fNSteuF0eCC7kGTxTW2zrPyaLIDErF4+KCdDKRgoct/" -DISCORD_REDIRECT_URI=encrypted:BIVlGoCrhTUQE6e8j0Ol7oi/5DMHblTRfWiexZg24VZrD2t7gomiZn1ylMo0YYHNm2B7jf+TkRTlNg36wUukhkl5L+qQRGaVYuhiD57s8Hzv9Z+8zcREoqKu8jTtA8CKTYHFmaiwRfYQJAxNzWRCGNp1oAUd1TpXUEZ03hORaIgD6DJAu6OeaipofKs= +DISCORD_CLIENT_ID="encrypted:BPUCg9hzJyK2glzRIfXi2Ok1B9MUPIVNK7kOVRzfKHEmmsBFpQBYm7a2Qzbrjrg6j72KoMvw8oR3DnZEM4CUmNiYhjmoKyERyutV0BZ0+WuWgezykEFM0xFE/ERl/XTWytLm1/972Z272k/0bhambG5qvhg=" +DISCORD_CLIENT_SECRET="encrypted:BGKAsGe/w5tvZDN/qWU6qHly1NDxugwkyqg+nBwf+5GtvaY30mhfymevELebjYXAfykHiJ5uTOUDW0GBWHVhEzvAAjCARQFnKfDE6PQeJv21wzAszOzS/YcikbeIvwjHavGqFDddvwJa2YvIjw+duh4b/CcrWMOUgP78IB2w+mYS" +DISCORD_REDIRECT_URI=encrypted:BNI/hR+kYuIcgBhw3hT3PEDOafhjk13jsixFQhl6XISy75/4yIY1UP3eHBT3ra5Rs5oimIIkeMXHbp5MfMGFKA90vN7YnT9XGus8POGVi1t3+4aNoB06ke1/SIvByb9SDoYQYo4XT7LVf29n0khY9Rub9UbXBr3ljy3yR3Z03wFQFVFEnHg5PeOXZPE= -SMTP_HOST="encrypted:BAzR0UdCJHlutOl01cw6kzPRseaot+cr+7ePUSKOcorXrGE4d2BWvQCT5A37qydjieSN98H1lV+9nykNMYUAD3PniY7/nd9DD6bDmXnG1nKACedCCF/fTPgfct0PMSN+FmgJI6UMbP45Q/l/QM3eRBJcqQ==" -SMTP_PORT="encrypted:BEfvu12wUPhrlVp1ICDAE6ohx5rKsUWGcnYnqL40VwafI8nayK9DqU5tuxag5du4E9lkHZ7QOLbn9QRw3sR3Yl74C+PlIktQWSQ5qCWMh2qbjYmulF8uTSWNXpPhIlhCyYVipA==" -SMTP_SECURE="encrypted:BHsWBT1w//boBqindVIcD3KOaDcjBfuHb+cm6ph5zP3dMT+oaFlG7msV28uPnbL5ZtOFXC6sUzVicMeqktu9O5ciQ+K9CFM+q+GStQBFqJ1SbfEq0isspV9eXWcTGPmTZAfBDxFx" -SMTP_USER="encrypted:BL5lI1+d91LAlbLyGCZ343Uqq55McVdF7Jo+e33EauaChfg+ltKB2d4lFGm7KHkQ+K9CorxlPpR82YjssS7ixYWwbEkFL7PgOgWxF8ksKQHDL8UR1zaqTmyylKseVfj/YIquaA7nS5FsjzpWCDVQFETJNw==" -SMTP_PASSWORD="encrypted:BNsDbxMZqE142HZ8PyEytL9mG7L2y79rMvM6aqDcs0ONNYtjM2ffDwKbu4ZySwkv6NS6mdKtHTVg3/tGmvdPRjbuK4f6FpRsJEnQvCwLaN0Gy9ikeCMOwrqQCOyfJSTkNTBiOvrz5e/Id0BXYjOkShk=" -SMTP_FROM="encrypted:BDsOlqmQFyAGOOJ2f+h9AS+CmV3uxEpy1mJaNzWIGFI1NMumy9s+dFgvKrunhAIImDSzod5BphAAOtUW8RyrIEO5wJvB1IJ+qoAw06hSWa4qWu1thdSu2ZEP3D+4ll7kpsOcMEU5PVFlSHQRLsPRdOS5mSGCy5VkXb2zhNAQ0A==" +SMTP_HOST="encrypted:BIxKDxHcN3/bwHQVILQMP5FakAD6ixciFWK0kYfdSZl3YX1BVK8+KAPxNS2LXd83wI8swM+f8TJpaVYsE/teALY3rA67KgvyZH09nmH0VvuKmdA5jYia08m/ZXb69a1W5cWMEkzG/l+ERGG7jJD38aLKlA==" +SMTP_PORT="encrypted:BNzY9VsJiCyPiHD60VI8Lu+QgyA88G5Rxsnxo4Zc2cGiPdTjT6u4yjzlZS/m3DqRN3mHtY/C7p1qRRXu1uj/JRku6t7JiaEyZnoYS1cfdS0tpmwTiqhHzVlpBUiX+2ImpRfYOw==" +SMTP_SECURE="encrypted:BGzfmeSU+mRw/YpYAu7svFBzsWVwxjwCrnzQJQ4t6JKGVLTDCv4vdSYZBxO9fBvpJD0QQyyndnHxHAvlENMy2AEv2WaB2SICf5T5AUwvENH9kPkX5h/M43Diji3n30qh/Gm53pGo" +SMTP_USER="encrypted:BHR+Buh73QAfXrLWEJ0ipXStkQJuVhSdK6F5AW/9/XXcvvhd0WZnJXXIVg4niP6VkW6R+/IoW7jzkUR7VUez7Z44sOFWIfe2M3uxrEDCeu/zNmhZaV5YCuBXj/pcEVb3/eUAiMgI1NhUez2/MCw29gV1FA==" +SMTP_PASSWORD="encrypted:BJksY/oTv8MWWnN/I0YkEjhSLK94TqX0LrnukecdgSOLQoRydqB52gBD7iUwqQkiDs70LCsSsDROZnJvLgfI/OHWTpIr69t2cPCjAcQjBwsRFXAt5hmaTeAOAVlSPleuCNRqxHOHJckiH/alSB/9yPM=" +SMTP_FROM="encrypted:BAuwcaQj+CpkOO3teZdpbkVP2P/5DffxIm4WRlkPAS6uw1jlLkPKOwrdNMms6w+QqvugvL44J9ScrK5bSfNryUBaupwY+hKisqi4zzEPcwEqmychyMP8O/m65lQsBYI9oH/BLruNns8jXBTtNBkMcY95hwAqL3FQHhAdGN7wdQ==" -ORIGIN="encrypted:BHOwfV6ahbNXg8A7HNbApjaDrQiwVXSI5f/tBx/eeFYdBxgQJ8zQQKUOzSeICdOtx1z9BxIOWHr86+E8ZG67k0RxSeevpSLWe8bINOy+7Xf9IzXBgg2aOpY0gWxAcXercLKvP+UNJJy3TuEl9sQZqRJBjNPyhyJB" -SCHEDULER_TOKEN="encrypted:BOQwJdKcGoxWA70HS/7Rpv6uQiupCw+QuY54At7zuDZFopdUCOj+lu2N9I8L+497bROGBsWG2knGzu4NXvb264PS6c5kk3UGrcctAPMalN4Cb9mAm/ZHeC0rvTkF0Lk50/2sesc9Zmi8Qess8+hSRdyAttt8V7YoT6b4kDsX6sK1ws+s6cd+IG7Oxfc=" +ORIGIN="encrypted:BGcNYe6p2UIQF0clcq7riYodr67GX27F9tHDU4b3I85c2rXyE5c9igAQxZgZs5YXDIAt9FG3lHt/QsXYNFzSruwBE+7cXHMN8i6vGxDaxFepMjhDsLhDd911BnPZH0ENuc5GrtS6aAKXRj5kGz42eXh+Dpj2JqCI" +SCHEDULER_TOKEN="encrypted:BK3ud4X3Vsou3bURV/0iaK7Tye06UK8UJJy6i6rUsMJi7QcfFjuHYgNdupFCn6DRO6u0TzlpFSa87tnWQeKUaK+SRb+nxSrdGoejY3S5abqlOMxKTUyCGvoYQbECbto2PXasxEb4iDcpIi5ZPIupVk9CHz/jCmZDAlxp53bOyIQtXEMUdx0Er0VgFU4=" +REMOVE_BG_API_KEY=encrypted:BJg92nPiZcW+3jPgds3uVIIvGGOJDhabNeHTdReragLV0S9AKfuktG3DG5pq7QSVLh3ZIsGOTgrFkp35nlIYX2KxYhFivpccmZ5AQSYoEHEKiBJkBMPwwkS+PXMAVEW8LWMg9ieapwBnd3z5UNuj+OH9T72gNBahWg== diff --git a/database/schema.sql b/database/schema.sql index e0eaeb6..fe423b6 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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-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-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 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'); @@ -783,3 +784,21 @@ CREATE TABLE IF NOT EXISTS game_data_icons ( updated_at TEXT NOT NULL, 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); diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index b136e12..9714576 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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 ISAC-only safety backups before replacement. This is intentionally manual and 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 +``` diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 8225b7a..3dca3f2 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1564,6 +1564,31 @@ navigation regions. Search fields have visible or accessible labels, keyboard focus uses Signal Orange, and collapsed state must remain understandable 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 Every writable game-data module states what Save will do before the operator diff --git a/package.json b/package.json index e6d2590..dfe8aab 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "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: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", "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", diff --git a/scripts/environment-bootstrap-export.mjs b/scripts/environment-bootstrap-export.mjs index 09bb853..773d053 100644 --- a/scripts/environment-bootstrap-export.mjs +++ b/scripts/environment-bootstrap-export.mjs @@ -47,7 +47,7 @@ try { }; await mkdir(dirname(outputPath), { recursive: true }); 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}`); } finally { await rm(temporaryBackupPath, { force: true }); diff --git a/scripts/environment-bootstrap-import.mjs b/scripts/environment-bootstrap-import.mjs index 205bef7..962fe89 100644 --- a/scripts/environment-bootstrap-import.mjs +++ b/scripts/environment-bootstrap-import.mjs @@ -111,5 +111,5 @@ for (const file of bundle.files.gameIcons) { 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}`); diff --git a/scripts/isac-data-export.mjs b/scripts/isac-data-export.mjs index 660f300..999be90 100644 --- a/scripts/isac-data-export.mjs +++ b/scripts/isac-data-export.mjs @@ -24,9 +24,10 @@ for (const name of ISAC_DATA_TABLES) { const 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)) { - 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)); icons.push({ storageKey: row.storage_key, contentBase64: content.toString('base64') }); @@ -44,5 +45,5 @@ const snapshot = { await mkdir(dirname(outputPath), { recursive: true }); 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}`); diff --git a/scripts/isac-data-import.mjs b/scripts/isac-data-import.mjs index de67a97..a608537 100644 --- a/scripts/isac-data-import.mjs +++ b/scripts/isac-data-import.mjs @@ -74,6 +74,6 @@ for (const icon of snapshot.icons) { 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(`ISAC pre-import snapshot: ${isacBackupPath}`); diff --git a/scripts/isac-data-tables.mjs b/scripts/isac-data-tables.mjs index c3a8460..6935b03 100644 --- a/scripts/isac-data-tables.mjs +++ b/scripts/isac-data-tables.mjs @@ -48,7 +48,8 @@ export const ISAC_DATA_TABLES = [ 'gear_mod_quality_values', 'gear_mod_secondary_options', 'gear_mod_secondary_quality_values', - 'game_data_icons' + 'game_data_icons', + 'managed_images' ]; export const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`; diff --git a/scripts/managed-images-export.mjs b/scripts/managed-images-export.mjs new file mode 100644 index 0000000..35a7f8e --- /dev/null +++ b/scripts/managed-images-export.mjs @@ -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}`); diff --git a/scripts/managed-images-import.mjs b/scripts/managed-images-import.mjs new file mode 100644 index 0000000..de57ef6 --- /dev/null +++ b/scripts/managed-images-import.mjs @@ -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 --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.`); diff --git a/src/app.css b/src/app.css index d8b1bf3..d6e7de6 100644 --- a/src/app.css +++ b/src/app.css @@ -792,3 +792,4 @@ .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}} diff --git a/src/lib/components/IconUploadField.svelte b/src/lib/components/IconUploadField.svelte index 74aea8a..8df2064 100644 --- a/src/lib/components/IconUploadField.svelte +++ b/src/lib/components/IconUploadField.svelte @@ -9,7 +9,7 @@
{label} - + {#if currentUrl}{/if} - OPTIONAL · MAY BE CONTRIBUTED LATER · TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX + OPTIONAL · SVG, PNG, WEBP, AVIF, OR JPEG · OPAQUE BACKGROUNDS REMOVED WHEN NEEDED · 2 MiB MAX
diff --git a/src/lib/server/game-data-icons.ts b/src/lib/server/game-data-icons.ts index 175bff9..fc99a70 100644 --- a/src/lib/server/game-data-icons.ts +++ b/src/lib/server/game-data-icons.ts @@ -2,6 +2,7 @@ import type { Database } from '@tursodatabase/database'; import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; 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 type GameDataIconType = (typeof GAME_ICON_TYPES)[number]; @@ -42,7 +43,7 @@ export async function processGameDataIconUpload(file: File): Promise { if (!file.size || file.size > MAX_GAME_ICON_BYTES) { 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 { const looksLikeSvg = file.type === 'image/svg+xml' || input.subarray(0, 512).toString('utf8').toLowerCase().includes(' { throw new GameDataIconValidationError('SVG icons cannot contain scripts, embedded images, or external resources.'); } } - const image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' }); - const metadata = await image.metadata(); - if (!metadata.format || !['png', 'webp', 'avif', 'svg'].includes(metadata.format) || !metadata.hasAlpha) { - throw new GameDataIconValidationError('Use an SVG, PNG, WebP, or AVIF icon with a transparent background.'); + let image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' }); + let metadata = await image.metadata(); + if (!metadata.format || !['jpeg', 'png', 'webp', 'avif', 'svg'].includes(metadata.format)) { + throw new GameDataIconValidationError('Use a JPEG, SVG, PNG, WebP, or AVIF icon.'); } - const stats = await image.stats(); - if (stats.isOpaque) { - throw new GameDataIconValidationError('The icon must contain transparent background pixels around the artwork.'); + let stats = await image.stats(); + if (!metadata.hasAlpha || stats.isOpaque) { + 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 .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, { fit: 'contain', position: 'centre', @@ -75,6 +87,7 @@ export async function processGameDataIconUpload(file: File): Promise { .toBuffer(); } catch (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.'); } } diff --git a/src/lib/server/managed-images.ts b/src/lib/server/managed-images.ts new file mode 100644 index 0000000..90b2979 --- /dev/null +++ b/src/lib/server/managed-images.ts @@ -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) + || / 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('> { + 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; +} diff --git a/src/routes/images/library/[id]/+server.ts b/src/routes/images/library/[id]/+server.ts new file mode 100644 index 0000000..e27d6ac --- /dev/null +++ b/src/routes/images/library/[id]/+server.ts @@ -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' + } + }); +}; diff --git a/src/routes/isac/+layout.svelte b/src/routes/isac/+layout.svelte index 702dc58..73b9284 100644 --- a/src/routes/isac/+layout.svelte +++ b/src/routes/isac/+layout.svelte @@ -36,6 +36,7 @@ ]; const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission))); const systemSections = [ + { href: '/isac/images', label: 'Image library', permission: 'images.manage' }, { href: '/isac/system-messages', label: 'Automated messages', permission: 'system.notifications.manage' }, { href: '/isac/metrics', label: 'System metrics', permission: 'metrics.view' } ]; diff --git a/src/routes/isac/+page.server.ts b/src/routes/isac/+page.server.ts index 114449c..ce21474 100644 --- a/src/routes/isac/+page.server.ts +++ b/src/routes/isac/+page.server.ts @@ -9,6 +9,7 @@ export const load: PageServerLoad = async ({ cookies }) => { 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('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('metrics.view')) redirect(303, '/isac/metrics'); redirect(303, '/account'); diff --git a/src/routes/isac/images/+page.server.ts b/src/routes/isac/images/+page.server.ts new file mode 100644 index 0000000..e7d5bda --- /dev/null +++ b/src/routes/isac/images/+page.server.ts @@ -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; + } + } +}; diff --git a/src/routes/isac/images/+page.svelte b/src/routes/isac/images/+page.svelte new file mode 100644 index 0000000..72168fc --- /dev/null +++ b/src/routes/isac/images/+page.svelte @@ -0,0 +1,91 @@ + + +Image library — ISAC +
+

SYSTEM CONFIGURATION

IMAGE LIBRARY

Process, classify, and locate reusable SHD.build visual assets.

+
+
+
+
ASSETS{Number(data.stats.total)}Managed files
+
ICONS{Number(data.stats.icons)}Normalized assets
+
CATEGORIES{Number(data.stats.categories)}Assignments in use
+
STORAGE{size(data.stats.bytes)}Processed WebP
+
+ +{#if adding} +
+

NEW MANAGED ASSET

UPLOAD + PROCESS

+ +
+
+ +
+ {#if form?.imageError}

{form.imageError}

{/if} +
+
+
+{/if} + +{#if editing} +
+

EDIT LIBRARY METADATA

{editing.name}

+ + + + {#if form?.imageError}

{form.imageError}

{/if} +
+
+
+{/if} + + +
+ {#each data.images as image} +
+
Preview of {image.name}
+
{image.name}{image.category}
{image.is_icon ? 'ICON' : 'IMAGE'}
+
{#each image.tags as tag}{tag}{/each}{#if !image.tags.length}NO TAGS{/if}
+
{image.width} × {image.height} · {size(image.byte_size)}
+
+ {:else} +
NO MATCHING ASSETSUpload the first image or revise the registry search.
+ {/each} +