From 23f2e06c098ed4192481587f8d0990ac04658228 Mon Sep 17 00:00:00 2001 From: dallensmith Date: Sat, 6 Jun 2026 01:35:29 -0400 Subject: [PATCH] feat(deps): add sharp image processing library --- package-lock.json | 539 +++++++++++++++++++++++- package.json | 3 +- src/lib/server/cdn.ts | 188 +++++++++ src/routes/+layout.svelte | 55 ++- src/routes/+page.server.ts | 49 +++ src/routes/+page.svelte | 267 +++++++++++- src/routes/admin/assets/+page.server.ts | 79 ++++ src/routes/admin/assets/+page.svelte | 439 +++++++++++++++++++ src/routes/api/assets/+server.ts | 113 +++++ 9 files changed, 1710 insertions(+), 22 deletions(-) create mode 100644 src/lib/server/cdn.ts create mode 100644 src/routes/+page.server.ts create mode 100644 src/routes/admin/assets/+page.server.ts create mode 100644 src/routes/admin/assets/+page.svelte create mode 100644 src/routes/api/assets/+server.ts diff --git a/package-lock.json b/package-lock.json index 069820b..47e776b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "drizzle-orm": "^0.45.2", "kysely": "^0.29.2", "pg": "^8.21.0", - "postgres": "^3.4.5" + "postgres": "^3.4.5", + "sharp": "^0.34.5" }, "devDependencies": { "@eslint/js": "^9.15.0", @@ -175,6 +176,16 @@ "devOptional": true, "license": "Apache-2.0" }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild-kit/core-utils": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", @@ -1276,6 +1287,471 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2674,6 +3150,15 @@ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devalue": { "version": "5.8.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", @@ -4248,7 +4733,6 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4263,6 +4747,50 @@ "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", "license": "MIT" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4559,6 +5087,13 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", diff --git a/package.json b/package.json index d88af01..b109829 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "drizzle-orm": "^0.45.2", "kysely": "^0.29.2", "pg": "^8.21.0", - "postgres": "^3.4.5" + "postgres": "^3.4.5", + "sharp": "^0.34.5" } } diff --git a/src/lib/server/cdn.ts b/src/lib/server/cdn.ts new file mode 100644 index 0000000..1011c0b --- /dev/null +++ b/src/lib/server/cdn.ts @@ -0,0 +1,188 @@ +import { env } from '$env/dynamic/private'; +import sharp from 'sharp'; +import { randomUUID } from 'crypto'; + +// ─── Bunny CDN Configuration ───────────────────────────────────────────────── + +function getConfig() { + const baseUrl = env.CDN_BASE_URL; + const storageEndpoint = env.CDN_STORAGE_ENDPOINT; + const accessKey = env.CDN_ACCESS_KEY; + const bucket = env.CDN_BUCKET; + + if (!baseUrl || !storageEndpoint || !accessKey || !bucket) { + throw new Error( + 'CDN is not configured. Set CDN_BASE_URL, CDN_STORAGE_ENDPOINT, CDN_ACCESS_KEY, and CDN_BUCKET in your .env file.' + ); + } + + return { baseUrl, storageEndpoint, accessKey, bucket }; +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface CdnUploadResult { + cdnKey: string; + cdnUrl: string; + width: number; + height: number; + size: number; + mimeType: string; +} + +// ─── Key Generation ─────────────────────────────────────────────────────────── + +/** + * Generate a CDN storage key for a new upload. + * Format: sites/{siteSlug}/{type}/{uuid}.webp + */ +export function generateCdnKey(siteSlug: string, type: string): string { + const id = randomUUID(); + return `sites/${siteSlug}/${type}/${id}.webp`; +} + +// ─── Public URL ─────────────────────────────────────────────────────────────── + +/** + * Build the public CDN URL for a stored file. + */ +export function getCdnUrl(cdnKey: string): string { + const { baseUrl } = getConfig(); + const normalizedBase = baseUrl.replace(/\/+$/, ''); + const normalizedKey = cdnKey.replace(/^\/+/, ''); + return `${normalizedBase}/${normalizedKey}`; +} + +// ─── WebP Conversion ────────────────────────────────────────────────────────── + +const MAX_SIZE = 5 * 1024 * 1024; // 5MB +const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp']; + +/** + * Validate and convert an uploaded image buffer to webp. + * Returns the webp buffer and image metadata. + */ +export async function convertToWebP( + buffer: Buffer, + originalMimeType: string +): Promise<{ webpBuffer: Buffer; width: number; height: number }> { + if (!ALLOWED_TYPES.includes(originalMimeType)) { + throw new Error( + `Unsupported file type: ${originalMimeType}. Allowed: PNG, JPEG, WebP.` + ); + } + + if (buffer.length > MAX_SIZE) { + const sizeMB = (buffer.length / (1024 * 1024)).toFixed(1); + throw new Error(`File too large: ${sizeMB}MB. Maximum size is 5MB.`); + } + + const image = sharp(buffer); + const metadata = await image.metadata(); + + if (!metadata.width || !metadata.height) { + throw new Error('Could not read image dimensions.'); + } + + const webpBuffer = await image.webp({ quality: 82 }).toBuffer(); + + return { + webpBuffer, + width: metadata.width, + height: metadata.height + }; +} + +// ─── Upload ─────────────────────────────────────────────────────────────────── + +/** + * Upload a buffer to Bunny CDN storage. + * Returns the CDN key and public URL. + */ +export async function uploadToCdn( + buffer: Buffer, + siteSlug: string, + type: string, + originalMimeType: string +): Promise { + const { storageEndpoint, accessKey, bucket } = getConfig(); + + // Convert to webp + const { webpBuffer, width, height } = await convertToWebP(buffer, originalMimeType); + + // Generate key and upload + const cdnKey = generateCdnKey(siteSlug, type); + const normalizedEndpoint = storageEndpoint.replace(/\/+$/, ''); + const normalizedBucket = bucket.replace(/^\/+/, ''); + const uploadUrl = `${normalizedEndpoint}/${normalizedBucket}/${cdnKey}`; + + const response = await fetch(uploadUrl, { + method: 'PUT', + headers: { + AccessKey: accessKey, + 'Content-Type': 'image/webp' + }, + body: new Uint8Array(webpBuffer) + }); + + if (!response.ok) { + const text = await response.text().catch(() => 'Unknown error'); + throw new Error(`CDN upload failed (${response.status}): ${text}`); + } + + const cdnUrl = getCdnUrl(cdnKey); + + return { + cdnKey, + cdnUrl, + width, + height, + size: webpBuffer.length, + mimeType: 'image/webp' + }; +} + +// ─── Delete ─────────────────────────────────────────────────────────────────── + +/** + * Delete a file from Bunny CDN storage. + */ +export async function deleteFromCdn(cdnKey: string): Promise { + const { storageEndpoint, accessKey, bucket } = getConfig(); + + const normalizedEndpoint = storageEndpoint.replace(/\/+$/, ''); + const normalizedBucket = bucket.replace(/^\/+/, ''); + const deleteUrl = `${normalizedEndpoint}/${normalizedBucket}/${cdnKey}`; + + const response = await fetch(deleteUrl, { + method: 'DELETE', + headers: { + AccessKey: accessKey + } + }); + + if (!response.ok) { + const text = await response.text().catch(() => 'Unknown error'); + throw new Error(`CDN delete failed (${response.status}): ${text}`); + } +} + +// ─── Sanity Check ───────────────────────────────────────────────────────────── + +/** + * Verify CDN connectivity at startup (optional). + * Returns true if the CDN endpoint is reachable. + */ +export async function checkCdnConnectivity(): Promise { + try { + const { storageEndpoint, accessKey } = getConfig(); + // Simple HEAD or GET to the storage root to verify credentials + const response = await fetch(storageEndpoint.replace(/\/+$/, ''), { + method: 'GET', + headers: { AccessKey: accessKey } + }); + return response.ok || response.status === 404; // 404 means endpoint reachable, just no file + } catch { + return false; + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ae9c9d0..4ed109f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,7 +1,58 @@ -{@render children()} +
+ {@render children()} +
+ + diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..5aa00b9 --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,49 @@ +import type { PageServerLoad } from './$types'; + +/** + * Homepage server load — flattens site settings into simple props. + * + * All data originates from the root layout server (+layout.server.ts). + * This loader calls event.parent() to access that cached data so we + * make zero additional database queries here. + * + * Fallback chain for each prop ensures the page always has sensible + * values, even when settings have never been configured by the owner. + */ +export const load: PageServerLoad = async (event) => { + const parent = await event.parent(); + const { site, siteSettings, user, membership } = parent; + + const homepage = siteSettings?.homepage; + const branding = siteSettings?.branding; + + // heroTitle: homepage.heroTitle → branding.siteName → site.name → 'My Site' + const heroTitle = homepage?.heroTitle || branding?.siteName || site?.name || 'My Site'; + + // heroSubtitle: homepage.heroSubtitle → branding.tagline → '' + const heroSubtitle = homepage?.heroSubtitle || branding?.tagline || ''; + + // aboutText: homepage.aboutText → '' + const aboutText = homepage?.aboutText || ''; + + // CTA + const ctaText = homepage?.primaryButtonText || ''; + const ctaLink = homepage?.primaryButtonLink || ''; + + // Show flags (use ?? so explicit false is respected) + const showNextEvent = homepage?.showNextEvent ?? false; + const showSchedule = homepage?.showSchedule ?? false; + + return { + site, + heroTitle, + heroSubtitle, + aboutText, + ctaText, + ctaLink, + showNextEvent, + showSchedule, + user, + membership + }; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b46e8ad..233ae95 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,25 +2,258 @@ import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); + + /** + * Determine if a link is external (starts with http:// or https://). + * Internal links (starting with `/`) navigate normally. + */ + function isExternal(href: string): boolean { + return /^https?:\/\//.test(href); + } -{#if data.site} -

{data.site.name}

-{/if} + + {data.heroTitle} + -{#if data.siteSettings?.branding?.tagline} -

{data.siteSettings.branding.tagline}

-{/if} - -{#if data.user} -

Welcome, {data.user.discordUsername}!

- {#if data.membership} -

Role: {data.membership.role}

- Admin Panel - {:else} -

You are logged in but not a member of this site.

- {/if} - Sign Out + +{#if !data.site} +
+

Site not configured

+

Check the SITE_SLUG environment variable and ensure a matching row exists in the sites table.

+

Run npm run db:seed to create the default "local-dev" site.

+
{:else} -

Your site is running. Login with Discord to manage it.

+ + + +
+
+

{data.heroTitle}

+ + {#if data.heroSubtitle} +

{data.heroSubtitle}

+ {/if} + + {#if data.ctaText && data.ctaLink} + {@const external = isExternal(data.ctaLink)} + + {data.ctaText} + + {/if} +
+
+ + + + + {#if data.aboutText} +
+
+

About

+

{data.aboutText}

+
+
+ {/if} + + + + +
+ +
{/if} + + diff --git a/src/routes/admin/assets/+page.server.ts b/src/routes/admin/assets/+page.server.ts new file mode 100644 index 0000000..e221b94 --- /dev/null +++ b/src/routes/admin/assets/+page.server.ts @@ -0,0 +1,79 @@ +import { db } from '$lib/server/db'; +import { assets } from '$lib/server/db/schema'; +import { eq, and, desc } from 'drizzle-orm'; +import { getCdnUrl, deleteFromCdn } from '$lib/server/cdn'; +import type { PageServerLoad, Actions } from './$types'; + +/** + * Load all assets for the current site, newest first. + * Computes the public CDN URL for each asset. + */ +export const load: PageServerLoad = async (event) => { + const { site } = event.locals; + + if (!site) { + return { assetList: [] }; + } + + const rows = await db + .select() + .from(assets) + .where(eq(assets.siteId, site.id)) + .orderBy(desc(assets.createdAt)) + .limit(100); + + const assetList = rows.map((a) => ({ + id: a.id, + filename: a.filename, + mimeType: a.mimeType, + size: a.size, + cdnKey: a.cdnKey, + cdnUrl: getCdnUrl(a.cdnKey), + createdAt: a.createdAt + })); + + return { assetList }; +}; + +/** + * Form action: delete an asset by id. + */ +export const actions: Actions = { + delete: async (event) => { + const { site } = event.locals; + + if (!site) { + return { success: false, error: 'No site context.' }; + } + + const formData = await event.request.formData(); + const assetId = formData.get('assetId')?.toString(); + + if (!assetId) { + return { success: false, error: 'Missing asset ID.' }; + } + + // Find the asset (scoped to site) + const [record] = await db + .select() + .from(assets) + .where(and(eq(assets.id, assetId), eq(assets.siteId, site.id))) + .limit(1); + + if (!record) { + return { success: false, error: 'Asset not found.' }; + } + + // Delete from CDN (soft-fail: log but don't block) + try { + await deleteFromCdn(record.cdnKey); + } catch (err) { + console.error('CDN delete failed for', record.cdnKey, err); + } + + // Delete from database + await db.delete(assets).where(eq(assets.id, assetId)); + + return { success: true }; + } +}; diff --git a/src/routes/admin/assets/+page.svelte b/src/routes/admin/assets/+page.svelte new file mode 100644 index 0000000..fdb56b4 --- /dev/null +++ b/src/routes/admin/assets/+page.svelte @@ -0,0 +1,439 @@ + + + + Assets — {data.site?.name ?? 'Admin'} + + +
+

Assets

+

Upload and manage images for your site. All uploads are converted to WebP.

+ + + {#if feedback} + + {/if} + + +
+ +
+ + + {#if data.assetList.length === 0} +
+

No assets uploaded yet. Click above to add your first image.

+
+ {:else} +
+ {#each data.assetList as asset} +
+
+ {asset.filename} +
+
+ {asset.filename} + {formatSize(asset.size)} · {formatDate(asset.createdAt)} +
+
+ + + {#if deletingId === asset.id} + + Sure? + + + + {:else} + + {/if} +
+
+ {/each} +
+ {/if} +
+ + +
+ +
+ + diff --git a/src/routes/api/assets/+server.ts b/src/routes/api/assets/+server.ts new file mode 100644 index 0000000..4dc94aa --- /dev/null +++ b/src/routes/api/assets/+server.ts @@ -0,0 +1,113 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { assets } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { uploadToCdn, deleteFromCdn } from '$lib/server/cdn'; + +/** + * POST /api/assets — Upload a new image asset. + * + * Expects multipart/form-data with a "file" field. + * Validates type (PNG/JPEG/WebP), max size (5MB), converts to webp, + * uploads to Bunny CDN, and creates an asset record in the database. + * + * Returns JSON: { id, cdnKey, cdnUrl, filename, mimeType, width, height, size } + */ +export const POST: RequestHandler = async (event) => { + const { site, user, membership } = event.locals; + + // Auth check: must be logged in with a membership + if (!user || !membership) { + error(401, 'You must be logged in as a site member to upload assets.'); + } + + if (!site) { + error(400, 'No site context. Check SITE_SLUG.'); + } + + const formData = await event.request.formData(); + const file = formData.get('file'); + + if (!(file instanceof File)) { + error(400, 'No file provided. Send a file in the "file" field.'); + } + + // Read file buffer + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const originalMimeType = file.type || 'application/octet-stream'; + + // Upload to CDN (validates, converts to webp) + const result = await uploadToCdn(buffer, site.slug, 'uploads', originalMimeType); + + // Create asset record in the database + const [record] = await db + .insert(assets) + .values({ + siteId: site.id, + uploadedByUserId: user.id, + type: 'image', + filename: file.name, + mimeType: result.mimeType, + size: result.size, + cdnKey: result.cdnKey + }) + .returning(); + + return json({ + id: record.id, + cdnKey: record.cdnKey, + cdnUrl: result.cdnUrl, + filename: record.filename, + mimeType: record.mimeType, + width: result.width, + height: result.height, + size: record.size + }); +}; + +/** + * DELETE /api/assets?id=... — Delete an asset. + * + * Removes the file from CDN storage and deletes the database record. + * Scoped to the current site for security. + */ +export const DELETE: RequestHandler = async (event) => { + const { site, user, membership } = event.locals; + + if (!user || !membership) { + error(401, 'You must be logged in as a site member to delete assets.'); + } + + if (!site) { + error(400, 'No site context.'); + } + + const url = new URL(event.request.url); + const assetId = url.searchParams.get('id'); + + if (!assetId) { + error(400, 'Missing asset "id" query parameter.'); + } + + // Find the asset record (scoped to the current site) + const [record] = await db + .select() + .from(assets) + .where(and(eq(assets.id, assetId), eq(assets.siteId, site.id))) + .limit(1); + + if (!record) { + error(404, 'Asset not found.'); + } + + // Delete from CDN + await deleteFromCdn(record.cdnKey); + + // Delete from database + await db.delete(assets).where(eq(assets.id, assetId)); + + return json({ success: true }); +};