diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml index 87f949f72..7c674326a 100644 --- a/.github/workflows/bot.yml +++ b/.github/workflows/bot.yml @@ -43,17 +43,19 @@ jobs: env: NODE_ENV: "test" services: - tachi-postgres: + tachi-postgres-test: image: postgres:18 env: POSTGRES_USER: tachi POSTGRES_PASSWORD: tachi POSTGRES_DB: postgres + PGDATA: /var/lib/postgresql/data/pgdata options: >- --health-cmd "pg_isready -U tachi" --health-interval 5s --health-timeout 5s --health-retries 10 + --tmpfs /var/lib/postgresql/data:rw,size=1g steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -71,6 +73,8 @@ jobs: run: bun run --filter tachi-bot test env: NODE_ENV: "test" + POSTGRES_TEST_HOST: tachi-postgres-test + POSTGRES_TEST_URL: postgresql://tachi:tachi@tachi-postgres-test docker-push: runs-on: ubuntu-latest diff --git a/Justfile-test b/Justfile-test index 761e52119..7a76f2a85 100644 --- a/Justfile-test +++ b/Justfile-test @@ -5,7 +5,7 @@ check: source .scripts/multi_evaluator.sh evaluate "just fmt-check" - evaluate "bun run --filter '*' lint" + evaluate "bun run --filter '*' lint -- --quiet" evaluate "just typecheck" evaluate "bun .scripts/ts_machete.js" evaluate "bun .scripts/ts_autoinherit.js --check" @@ -117,4 +117,4 @@ load-test-score-import *ARGS: # Seed N dev users + API tokens (submit_score); writes one token per line to OUTPUT_FILE. # Uses the server package env (.env); same Postgres as a local tachi-server. load-test-score-import-seed-tokens COUNT OUTPUT_FILE: - cd typescript/server && bun run src/load-tests/seed-stress-api-tokens.ts {{COUNT}} {{OUTPUT_FILE}} \ No newline at end of file + cd typescript/server && bun run src/load-tests/seed-stress-api-tokens.ts {{COUNT}} {{OUTPUT_FILE}} diff --git a/bun.lock b/bun.lock index 52950dd7a..5dc283545 100644 --- a/bun.lock +++ b/bun.lock @@ -273,7 +273,6 @@ "version": "0.1.0", "devDependencies": { "eslint-config-tachi": "workspace:*", - "prettier": "catalog:", "vite": "catalog:", }, }, @@ -384,6 +383,7 @@ "prom-client": "catalog:", "prudence": "catalog:", "rg-stats": "catalog:", + "sharp": "catalog:", "tachi-common": "workspace:*", "tachi-db": "workspace:*", "tachi-db-migration-engine": "workspace:*", @@ -551,6 +551,7 @@ "react-select": "^5.6.1", "rg-stats": "workspace:*", "sass": "^1.77.0", + "sharp": "^0.34.5", "sql.js": "^1.14.1", "supertest": "6.2.2", "sync-fetch": "^0.3.1", @@ -941,6 +942,56 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="], @@ -2623,6 +2674,8 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "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" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], diff --git a/package.json b/package.json index ff9d5bad3..053b3c1f4 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,7 @@ "react-router-dom": "5.1.2", "react-select": "^5.6.1", "rg-stats": "workspace:*", + "sharp": "^0.34.5", "sass": "^1.77.0", "supertest": "6.2.2", "sync-fetch": "^0.3.1", diff --git a/typescript/bot/vitest.globalSetup.ts b/typescript/bot/vitest.globalSetup.ts index 2a6fce040..4658df4d7 100644 --- a/typescript/bot/vitest.globalSetup.ts +++ b/typescript/bot/vitest.globalSetup.ts @@ -1,7 +1,7 @@ import { execSync } from "node:child_process"; import pg from "pg"; -const POSTGRES_HOST = "tachi-postgres"; +const POSTGRES_HOST = process.env.POSTGRES_TEST_HOST ?? "tachi-postgres-test"; const POSTGRES_USER = "tachi"; const POSTGRES_PASS = "tachi"; const TEMPLATE_DB = "tachi_bot_test_template"; diff --git a/typescript/bot/vitest.setup.ts b/typescript/bot/vitest.setup.ts index 3a41b0e3b..ca7fcee52 100644 --- a/typescript/bot/vitest.setup.ts +++ b/typescript/bot/vitest.setup.ts @@ -16,7 +16,7 @@ import { allImportTypes } from "tachi-common/constants/import-types"; const WORKER_ID = crypto.randomUUID().slice(0, 8); const WORKER_DB_NAME = `tachi_bot_test_${WORKER_ID}`; -const POSTGRES_HOST = "tachi-postgres"; +const POSTGRES_HOST = process.env.POSTGRES_TEST_HOST ?? "tachi-postgres-test"; const POSTGRES_USER = "tachi"; const POSTGRES_PASS = "tachi"; diff --git a/typescript/db/src/generated/index.ts b/typescript/db/src/generated/index.ts index e3a505d6a..18b9316c9 100644 --- a/typescript/db/src/generated/index.ts +++ b/typescript/db/src/generated/index.ts @@ -5,6 +5,7 @@ export { type priv_svc_cg_card_info_service, type default as PrivSvcCgCardInfoTa export { type score_rederive_chart_id, type default as ScoreRederiveTable, type ScoreRederive, type NewScoreRederive, type ScoreRederiveUpdate } from './public/ScoreRederive'; export { type class_achievement_row_id, type default as ClassAchievementTable, type ClassAchievement, type NewClassAchievement, type ClassAchievementUpdate } from './public/ClassAchievement'; export { type account_id, type default as AccountTable, type Account, type NewAccount, type AccountUpdate } from './public/Account'; +export { type import_timestop_import_type, type default as ImportTimestopTable, type ImportTimestop, type NewImportTimestop, type ImportTimestopUpdate } from './public/ImportTimestop'; export { type import_tracker_import_id, type default as ImportTrackerTable, type ImportTracker, type NewImportTracker, type ImportTrackerUpdate } from './public/ImportTracker'; export { type default as SvcFerSettingsTable, type SvcFerSettings, type NewSvcFerSettings, type SvcFerSettingsUpdate } from './public/SvcFerSettings'; export { type default as InviteLockTable, type InviteLock, type NewInviteLock, type InviteLockUpdate } from './public/InviteLock'; diff --git a/typescript/db/src/generated/public/ImportTimestop.ts b/typescript/db/src/generated/public/ImportTimestop.ts new file mode 100644 index 000000000..e94602e88 --- /dev/null +++ b/typescript/db/src/generated/public/ImportTimestop.ts @@ -0,0 +1,24 @@ +// @generated +// This file is automatically generated by Kanel. Do not modify manually. + +import type { default as ImportType } from './ImportType'; +import type { account_id } from './Account'; +import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; + +/** Identifier type for public.import_timestop */ +export type import_timestop_import_type = ImportType; + +/** Represents the table public.import_timestop */ +export default interface ImportTimestopTable { + user_id: ColumnType; + + import_type: ColumnType; + + last_score_time: ColumnType; +} + +export type ImportTimestop = Selectable; + +export type NewImportTimestop = Insertable; + +export type ImportTimestopUpdate = Updateable; diff --git a/typescript/db/src/generated/public/PublicSchema.ts b/typescript/db/src/generated/public/PublicSchema.ts index 8d8b02cb9..388032b17 100644 --- a/typescript/db/src/generated/public/PublicSchema.ts +++ b/typescript/db/src/generated/public/PublicSchema.ts @@ -5,6 +5,7 @@ import type { default as PrivSvcCgCardInfoTable } from './PrivSvcCgCardInfo'; import type { default as ScoreRederiveTable } from './ScoreRederive'; import type { default as ClassAchievementTable } from './ClassAchievement'; import type { default as AccountTable } from './Account'; +import type { default as ImportTimestopTable } from './ImportTimestop'; import type { default as ImportTrackerTable } from './ImportTracker'; import type { default as SvcFerSettingsTable } from './SvcFerSettings'; import type { default as InviteLockTable } from './InviteLock'; @@ -78,6 +79,8 @@ export default interface PublicSchema { account: AccountTable; + import_timestop: ImportTimestopTable; + import_tracker: ImportTrackerTable; svc_fer_settings: SvcFerSettingsTable; diff --git a/typescript/server/package.json b/typescript/server/package.json index 0139e7adb..3b5c4c010 100644 --- a/typescript/server/package.json +++ b/typescript/server/package.json @@ -68,6 +68,7 @@ "prom-client": "catalog:", "prudence": "catalog:", "rg-stats": "catalog:", + "sharp": "catalog:", "tachi-common": "workspace:*", "tachi-db": "workspace:*", "tachi-db-migration-engine": "workspace:*", diff --git a/typescript/server/src/actions/change-banner.ts b/typescript/server/src/actions/change-banner.ts index 59d1a0cd1..797a6f957 100644 --- a/typescript/server/src/actions/change-banner.ts +++ b/typescript/server/src/actions/change-banner.ts @@ -1,27 +1,46 @@ import { MakeAction } from "#lib/actions/actions"; -import { CDNStoreOrOverwrite } from "#lib/cdn/cdn"; +import { CDNStoreWithMeta } from "#lib/cdn/cdn"; import { GetProfileBannerURL } from "#lib/cdn/url-format"; import DB from "#services/pg/db"; import { HashSHA256 } from "#utils/crypto"; import { ExpectedErr } from "bliss"; +import sharp from "sharp"; + +/** Max dimensions for stored profile banners (used as a full-page background). */ +const BANNER_MAX_WIDTH = 1920; +const BANNER_MAX_HEIGHT = 1080; + +/** + * Resizes any image (including animated GIFs) to at most BANNER_MAX_WIDTH × BANNER_MAX_HEIGHT + * and re-encodes as WebP, preserving all animation frames. + */ +async function resizeBanner(buf: Buffer): Promise { + return sharp(buf, { animated: true }) + .resize(BANNER_MAX_WIDTH, BANNER_MAX_HEIGHT, { fit: "inside", withoutEnlargement: true }) + .webp({ quality: 85 }) + .toBuffer(); +} export const ACTION_ChangeBanner = MakeAction( "CHANGE_BANNER", async (taker, { "!fileBuffer": fileBuffer, fileMimetype }) => { - const contentHash = HashSHA256(fileBuffer); - if ( - fileMimetype === "image/jpeg" || - fileMimetype === "image/png" || - fileMimetype === "image/gif" + fileMimetype !== "image/jpeg" && + fileMimetype !== "image/png" && + fileMimetype !== "image/gif" ) { - await CDNStoreOrOverwrite(GetProfileBannerURL(taker.acct.id, contentHash), fileBuffer); - } else { - // GIF is deliberately not mentioned here - // as it's an easter egg + // GIF is deliberately not mentioned in the error message as it's an easter egg throw new ExpectedErr(400, "Invalid file - only JPG and PNG files are supported."); } + const storedBuffer = await resizeBanner(fileBuffer); + const contentHash = HashSHA256(storedBuffer); + + await CDNStoreWithMeta(GetProfileBannerURL(taker.acct.id, contentHash), storedBuffer, { + contentType: "image/webp", + cacheControl: "public, max-age=31536000, immutable", + }); + await DB.updateTable("account") .set({ custom_banner_location: contentHash }) .where("id", "=", taker.acct.id) diff --git a/typescript/server/src/actions/change-pfp.test.ts b/typescript/server/src/actions/change-pfp.test.ts index 0275fbd84..ca0c1daeb 100644 --- a/typescript/server/src/actions/change-pfp.test.ts +++ b/typescript/server/src/actions/change-pfp.test.ts @@ -1,28 +1,43 @@ -import { CDNDelete, CDNStoreOrOverwrite } from "#lib/cdn/cdn"; +/** + * Integration tests for ACTION_ChangePfp, ACTION_DeletePfp, ACTION_ChangeBanner, and + * ACTION_DeleteBanner. + * + * These tests hit real MinIO (via the test CDN config) and pass real image buffers through + * sharp, so they verify the full upload pipeline: resize → WebP encode → S3 store → DB update. + */ + +import { CDNDelete, CDNRetrieve } from "#lib/cdn/cdn"; import { GetProfileBannerURL, GetProfilePictureURL } from "#lib/cdn/url-format"; import DB from "#services/pg/db"; import { seedUser } from "#test-utils/pg-fixtures"; +import { GetKTDataBuffer } from "#test-utils/test-data"; import { HashSHA256 } from "#utils/crypto"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import sharp from "sharp"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { ACTION_ChangeBanner } from "./change-banner"; import { ACTION_ChangePfp } from "./change-pfp"; import { ACTION_DeleteBanner } from "./delete-banner"; import { ACTION_DeletePfp } from "./delete-pfp"; -vi.mock("#lib/cdn/cdn.js", () => ({ - CDNStoreOrOverwrite: vi.fn().mockResolvedValue(undefined), - CDNDelete: vi.fn().mockResolvedValue(undefined), - CDNRedirect: vi.fn(), -})); +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +/** Real 600×500 PNG (64 KB). Sharp will resize it to fit within 256×256. */ +const ACORN_PNG = GetKTDataBuffer("/images/acorn.png"); + +/** Minimal 4×4 single-frame GIF, generated once before all tests. */ +let MINIMAL_GIF: Buffer; + +beforeAll(async () => { + MINIMAL_GIF = await sharp({ + create: { background: { b: 0, g: 0, r: 255 }, channels: 3, height: 4, width: 4 }, + }) + .gif() + .toBuffer(); +}); // ─── Helpers ────────────────────────────────────────────────────────────────── -const JPEG_BUFFER = Buffer.from("fake-jpeg-data"); -const PNG_BUFFER = Buffer.from("fake-png-data"); -const GIF_BUFFER = Buffer.from("fake-gif-data"); -const BAD_BUFFER = Buffer.from("fake-webp-data"); - async function getPfpLocation(userId: number) { const row = await DB.selectFrom("account") .select("custom_pfp_location") @@ -60,104 +75,148 @@ async function seedUserWithBanner(userId: number, hash: string) { describe("ACTION_ChangePfp", () => { let userId: number; let username: string; + const cdnCleanup: string[] = []; beforeEach(async () => { - vi.clearAllMocks(); ({ id: userId, username } = await seedUser({ username: "test_user" })); + cdnCleanup.length = 0; }); - // ── Mimetype validation ─────────────────────────────────────────────────── - - it("returns { contentHash } for a JPEG file", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - - const result = await ACTION_ChangePfp(taker, { - "!fileBuffer": JPEG_BUFFER, - fileMimetype: "image/jpeg", - }); - - expect(result).toMatchObject({ contentHash: HashSHA256(JPEG_BUFFER) }); + afterEach(async () => { + await Promise.all(cdnCleanup.map((p) => CDNDelete(p))); }); - it("returns { contentHash } for a PNG file", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + // ── Resize + S3 integration ─────────────────────────────────────────────── - const result = await ACTION_ChangePfp(taker, { - "!fileBuffer": PNG_BUFFER, + it("resizes a PNG to fit within 256×256 and stores it as WebP in S3", async () => { + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; + + const { contentHash } = await ACTION_ChangePfp(taker, { + "!fileBuffer": ACORN_PNG, fileMimetype: "image/png", }); - expect(result).toMatchObject({ contentHash: HashSHA256(PNG_BUFFER) }); + const cdnPath = GetProfilePictureURL(userId, contentHash); + cdnCleanup.push(cdnPath); + const stored = await CDNRetrieve(cdnPath); + const meta = await sharp(stored, { animated: true }).metadata(); + + expect(meta.format).toBe("webp"); + expect(meta.width).toBeLessThanOrEqual(256); + expect(meta.height).toBeLessThanOrEqual(256); + expect(stored.length).toBeLessThan(ACORN_PNG.length); }); - it("returns { contentHash } for a GIF file", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + it("resizes a JPEG to fit within 256×256 and stores it as WebP in S3", async () => { + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; - const result = await ACTION_ChangePfp(taker, { - "!fileBuffer": GIF_BUFFER, + const { contentHash } = await ACTION_ChangePfp(taker, { + "!fileBuffer": ACORN_PNG, + fileMimetype: "image/jpeg", + }); + + const cdnPath = GetProfilePictureURL(userId, contentHash); + cdnCleanup.push(cdnPath); + const stored = await CDNRetrieve(cdnPath); + const meta = await sharp(stored, { animated: true }).metadata(); + + expect(meta.format).toBe("webp"); + expect(meta.width).toBeLessThanOrEqual(256); + expect(meta.height).toBeLessThanOrEqual(256); + }); + + it("converts a GIF to WebP and stores it in S3", async () => { + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; + + const { contentHash } = await ACTION_ChangePfp(taker, { + "!fileBuffer": MINIMAL_GIF, fileMimetype: "image/gif", }); - expect(result).toMatchObject({ contentHash: HashSHA256(GIF_BUFFER) }); + const cdnPath = GetProfilePictureURL(userId, contentHash); + cdnCleanup.push(cdnPath); + const stored = await CDNRetrieve(cdnPath); + const meta = await sharp(stored, { animated: true }).metadata(); + + expect(meta.format).toBe("webp"); }); - it("throws 400 for an unsupported mimetype", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + it("hashes the resized WebP output, not the original upload", async () => { + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; - await expect( - ACTION_ChangePfp(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }), - ).rejects.toMatchObject({ code: 400 }); + const { contentHash } = await ACTION_ChangePfp(taker, { + "!fileBuffer": ACORN_PNG, + fileMimetype: "image/png", + }); + + const cdnPath = GetProfilePictureURL(userId, contentHash); + cdnCleanup.push(cdnPath); + const stored = await CDNRetrieve(cdnPath); + + expect(contentHash).toBe(HashSHA256(stored)); + expect(contentHash).not.toBe(HashSHA256(ACORN_PNG)); }); // ── Database updates ────────────────────────────────────────────────────── - it("persists the content hash to custom_pfp_location", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + it("persists the content hash of the stored WebP to custom_pfp_location", async () => { + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; - await ACTION_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + const { contentHash } = await ACTION_ChangePfp(taker, { + "!fileBuffer": ACORN_PNG, + fileMimetype: "image/png", + }); - expect(await getPfpLocation(userId)).toBe(HashSHA256(JPEG_BUFFER)); + cdnCleanup.push(GetProfilePictureURL(userId, contentHash)); + + expect(await getPfpLocation(userId)).toBe(contentHash); }); it("does not update other users' custom_pfp_location", async () => { const other = await seedUser({ username: "other_user" }); - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; - await ACTION_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + const { contentHash } = await ACTION_ChangePfp(taker, { + "!fileBuffer": ACORN_PNG, + fileMimetype: "image/png", + }); + + cdnCleanup.push(GetProfilePictureURL(userId, contentHash)); expect(await getPfpLocation(other.id)).toBeNull(); }); - it("does not update custom_pfp_location on a bad mimetype", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + // ── Mimetype validation ─────────────────────────────────────────────────── + + it("throws 400 for an unsupported mimetype", async () => { + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; await expect( - ACTION_ChangePfp(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }), + ACTION_ChangePfp(taker, { "!fileBuffer": ACORN_PNG, fileMimetype: "image/webp" }), + ).rejects.toMatchObject({ code: 400 }); + }); + + it("does not update custom_pfp_location on a bad mimetype", async () => { + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; + + await expect( + ACTION_ChangePfp(taker, { "!fileBuffer": ACORN_PNG, fileMimetype: "image/webp" }), ).rejects.toThrow(); expect(await getPfpLocation(userId)).toBeNull(); }); - // ── CDN calls ───────────────────────────────────────────────────────────── - - it("calls CDNStoreOrOverwrite with the correct URL and buffer", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - - await ACTION_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); - - expect(CDNStoreOrOverwrite).toHaveBeenCalledOnce(); - expect(CDNStoreOrOverwrite).toHaveBeenCalledWith( - GetProfilePictureURL(userId, HashSHA256(JPEG_BUFFER)), - JPEG_BUFFER, - ); - }); - // ── Audit log ───────────────────────────────────────────────────────────── it("writes a GOOD action row on success", async () => { - const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "10.0.0.1" }; - await ACTION_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + const { contentHash } = await ACTION_ChangePfp(taker, { + "!fileBuffer": ACORN_PNG, + fileMimetype: "image/png", + }); + + cdnCleanup.push(GetProfilePictureURL(userId, contentHash)); const action = await DB.selectFrom("action") .selectAll() @@ -165,18 +224,18 @@ describe("ACTION_ChangePfp", () => { .executeTakeFirstOrThrow(); expect(action).toMatchObject({ + ip: "10.0.0.1", kind: "CHANGE_PFP", result: "GOOD", - ip: "10.0.0.1", user_id: userId, }); }); it("writes a BAD action row on invalid mimetype", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; await expect( - ACTION_ChangePfp(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }), + ACTION_ChangePfp(taker, { "!fileBuffer": ACORN_PNG, fileMimetype: "image/webp" }), ).rejects.toThrow(); const action = await DB.selectFrom("action") @@ -188,16 +247,21 @@ describe("ACTION_ChangePfp", () => { }); it("does not store the file buffer content in the audit log input", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; - await ACTION_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + const { contentHash } = await ACTION_ChangePfp(taker, { + "!fileBuffer": ACORN_PNG, + fileMimetype: "image/png", + }); + + cdnCleanup.push(GetProfilePictureURL(userId, contentHash)); const action = await DB.selectFrom("action") .select("input") .where("kind", "=", "CHANGE_PFP") .executeTakeFirstOrThrow(); - expect(JSON.stringify(action.input)).not.toContain(JPEG_BUFFER.toString("base64")); + expect(JSON.stringify(action.input)).not.toContain(ACORN_PNG.toString("base64")); }); }); @@ -208,20 +272,19 @@ describe("ACTION_DeletePfp", () => { let username: string; beforeEach(async () => { - vi.clearAllMocks(); ({ id: userId, username } = await seedUser({ username: "test_user" })); }); // ── 404 guard ───────────────────────────────────────────────────────────── it("throws 404 when the user has no custom pfp", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; await expect(ACTION_DeletePfp(taker, {})).rejects.toMatchObject({ code: 404 }); }); it("writes a BAD action row when the user has no custom pfp", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; await expect(ACTION_DeletePfp(taker, {})).rejects.toThrow(); @@ -235,22 +298,20 @@ describe("ACTION_DeletePfp", () => { // ── Success path ────────────────────────────────────────────────────────── - it("returns {} on success", async () => { - await seedUserWithPfp(userId, "existinghash"); - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + it("uploads a pfp then deletes it, removing it from S3", async () => { + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; - const result = await ACTION_DeletePfp(taker, {}); + const { contentHash } = await ACTION_ChangePfp(taker, { + "!fileBuffer": ACORN_PNG, + fileMimetype: "image/png", + }); - expect(result).toEqual({}); - }); - - it("clears custom_pfp_location to null in the DB", async () => { - await seedUserWithPfp(userId, "existinghash"); - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const cdnPath = GetProfilePictureURL(userId, contentHash); await ACTION_DeletePfp(taker, {}); expect(await getPfpLocation(userId)).toBeNull(); + await expect(CDNRetrieve(cdnPath)).rejects.toThrow(); }); it("does not touch other users' custom_pfp_location", async () => { @@ -258,30 +319,18 @@ describe("ACTION_DeletePfp", () => { await seedUserWithPfp(other.id, "otherhash"); await seedUserWithPfp(userId, "myhash"); - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; await ACTION_DeletePfp(taker, {}); expect(await getPfpLocation(other.id)).toBe("otherhash"); }); - // ── CDN calls ───────────────────────────────────────────────────────────── - - it("calls CDNDelete once with the correct URL", async () => { - await seedUserWithPfp(userId, "existinghash"); - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - - await ACTION_DeletePfp(taker, {}); - - expect(CDNDelete).toHaveBeenCalledOnce(); - expect(CDNDelete).toHaveBeenCalledWith(GetProfilePictureURL(userId, "existinghash")); - }); - // ── Audit log ───────────────────────────────────────────────────────────── it("writes a GOOD action row on success", async () => { await seedUserWithPfp(userId, "existinghash"); - const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "10.0.0.1" }; await ACTION_DeletePfp(taker, {}); @@ -291,9 +340,9 @@ describe("ACTION_DeletePfp", () => { .executeTakeFirstOrThrow(); expect(action).toMatchObject({ + ip: "10.0.0.1", kind: "DELETE_PFP", result: "GOOD", - ip: "10.0.0.1", user_id: userId, }); }); @@ -304,135 +353,146 @@ describe("ACTION_DeletePfp", () => { describe("ACTION_ChangeBanner", () => { let userId: number; let username: string; + const cdnCleanup: string[] = []; beforeEach(async () => { - vi.clearAllMocks(); ({ id: userId, username } = await seedUser({ username: "test_user" })); + cdnCleanup.length = 0; }); - // ── Mimetype validation ─────────────────────────────────────────────────── - - it("returns { contentHash } for a JPEG file", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - - const result = await ACTION_ChangeBanner(taker, { - "!fileBuffer": JPEG_BUFFER, - fileMimetype: "image/jpeg", - }); - - expect(result).toMatchObject({ contentHash: HashSHA256(JPEG_BUFFER) }); + afterEach(async () => { + await Promise.all(cdnCleanup.map((p) => CDNDelete(p))); }); - it("returns { contentHash } for a PNG file", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + // ── Resize + S3 integration ─────────────────────────────────────────────── - const result = await ACTION_ChangeBanner(taker, { - "!fileBuffer": PNG_BUFFER, + it("converts a PNG to WebP and stores it in S3", async () => { + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; + + const { contentHash } = await ACTION_ChangeBanner(taker, { + "!fileBuffer": ACORN_PNG, fileMimetype: "image/png", }); - expect(result).toMatchObject({ contentHash: HashSHA256(PNG_BUFFER) }); + const cdnPath = GetProfileBannerURL(userId, contentHash); + cdnCleanup.push(cdnPath); + const stored = await CDNRetrieve(cdnPath); + const meta = await sharp(stored, { animated: true }).metadata(); + + expect(meta.format).toBe("webp"); }); - it("returns { contentHash } for a GIF file", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + it("converts a GIF to WebP and stores it in S3", async () => { + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; - const result = await ACTION_ChangeBanner(taker, { - "!fileBuffer": GIF_BUFFER, + const { contentHash } = await ACTION_ChangeBanner(taker, { + "!fileBuffer": MINIMAL_GIF, fileMimetype: "image/gif", }); - expect(result).toMatchObject({ contentHash: HashSHA256(GIF_BUFFER) }); + const cdnPath = GetProfileBannerURL(userId, contentHash); + cdnCleanup.push(cdnPath); + const stored = await CDNRetrieve(cdnPath); + const meta = await sharp(stored, { animated: true }).metadata(); + + expect(meta.format).toBe("webp"); }); - it("throws 400 for an unsupported mimetype", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + it("hashes the resized WebP output, not the original upload", async () => { + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; - await expect( - ACTION_ChangeBanner(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }), - ).rejects.toMatchObject({ code: 400 }); + const { contentHash } = await ACTION_ChangeBanner(taker, { + "!fileBuffer": ACORN_PNG, + fileMimetype: "image/png", + }); + + const cdnPath = GetProfileBannerURL(userId, contentHash); + cdnCleanup.push(cdnPath); + const stored = await CDNRetrieve(cdnPath); + + expect(contentHash).toBe(HashSHA256(stored)); + expect(contentHash).not.toBe(HashSHA256(ACORN_PNG)); }); // ── Database updates ────────────────────────────────────────────────────── it("persists the content hash to custom_banner_location", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; - await ACTION_ChangeBanner(taker, { - "!fileBuffer": JPEG_BUFFER, - fileMimetype: "image/jpeg", + const { contentHash } = await ACTION_ChangeBanner(taker, { + "!fileBuffer": ACORN_PNG, + fileMimetype: "image/png", }); - expect(await getBannerLocation(userId)).toBe(HashSHA256(JPEG_BUFFER)); + cdnCleanup.push(GetProfileBannerURL(userId, contentHash)); + + expect(await getBannerLocation(userId)).toBe(contentHash); }); it("does not update other users' custom_banner_location", async () => { const other = await seedUser({ username: "other_user" }); - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; - await ACTION_ChangeBanner(taker, { - "!fileBuffer": JPEG_BUFFER, - fileMimetype: "image/jpeg", + const { contentHash } = await ACTION_ChangeBanner(taker, { + "!fileBuffer": ACORN_PNG, + fileMimetype: "image/png", }); + cdnCleanup.push(GetProfileBannerURL(userId, contentHash)); + expect(await getBannerLocation(other.id)).toBeNull(); }); - it("does not update custom_banner_location on a bad mimetype", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + // ── Mimetype validation ─────────────────────────────────────────────────── + + it("throws 400 for an unsupported mimetype", async () => { + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; await expect( - ACTION_ChangeBanner(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }), + ACTION_ChangeBanner(taker, { "!fileBuffer": ACORN_PNG, fileMimetype: "image/webp" }), + ).rejects.toMatchObject({ code: 400 }); + }); + + it("does not update custom_banner_location on a bad mimetype", async () => { + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; + + await expect( + ACTION_ChangeBanner(taker, { "!fileBuffer": ACORN_PNG, fileMimetype: "image/webp" }), ).rejects.toThrow(); expect(await getBannerLocation(userId)).toBeNull(); }); - // ── CDN calls ───────────────────────────────────────────────────────────── - - it("calls CDNStoreOrOverwrite with the correct URL and buffer", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - - await ACTION_ChangeBanner(taker, { - "!fileBuffer": JPEG_BUFFER, - fileMimetype: "image/jpeg", - }); - - expect(CDNStoreOrOverwrite).toHaveBeenCalledOnce(); - expect(CDNStoreOrOverwrite).toHaveBeenCalledWith( - GetProfileBannerURL(userId, HashSHA256(JPEG_BUFFER)), - JPEG_BUFFER, - ); - }); - // ── Audit log ───────────────────────────────────────────────────────────── it("writes a GOOD action row on success", async () => { - const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "10.0.0.1" }; - await ACTION_ChangeBanner(taker, { - "!fileBuffer": JPEG_BUFFER, - fileMimetype: "image/jpeg", + const { contentHash } = await ACTION_ChangeBanner(taker, { + "!fileBuffer": ACORN_PNG, + fileMimetype: "image/png", }); + cdnCleanup.push(GetProfileBannerURL(userId, contentHash)); + const action = await DB.selectFrom("action") .selectAll() .where("kind", "=", "CHANGE_BANNER") .executeTakeFirstOrThrow(); expect(action).toMatchObject({ + ip: "10.0.0.1", kind: "CHANGE_BANNER", result: "GOOD", - ip: "10.0.0.1", user_id: userId, }); }); it("writes a BAD action row on invalid mimetype", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; await expect( - ACTION_ChangeBanner(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }), + ACTION_ChangeBanner(taker, { "!fileBuffer": ACORN_PNG, fileMimetype: "image/webp" }), ).rejects.toThrow(); const action = await DB.selectFrom("action") @@ -444,19 +504,21 @@ describe("ACTION_ChangeBanner", () => { }); it("does not store the file buffer content in the audit log input", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; - await ACTION_ChangeBanner(taker, { - "!fileBuffer": JPEG_BUFFER, - fileMimetype: "image/jpeg", + const { contentHash } = await ACTION_ChangeBanner(taker, { + "!fileBuffer": ACORN_PNG, + fileMimetype: "image/png", }); + cdnCleanup.push(GetProfileBannerURL(userId, contentHash)); + const action = await DB.selectFrom("action") .select("input") .where("kind", "=", "CHANGE_BANNER") .executeTakeFirstOrThrow(); - expect(JSON.stringify(action.input)).not.toContain(JPEG_BUFFER.toString("base64")); + expect(JSON.stringify(action.input)).not.toContain(ACORN_PNG.toString("base64")); }); }); @@ -467,20 +529,19 @@ describe("ACTION_DeleteBanner", () => { let username: string; beforeEach(async () => { - vi.clearAllMocks(); ({ id: userId, username } = await seedUser({ username: "test_user" })); }); // ── 404 guard ───────────────────────────────────────────────────────────── it("throws 404 when the user has no custom banner", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; await expect(ACTION_DeleteBanner(taker, {})).rejects.toMatchObject({ code: 404 }); }); it("writes a BAD action row when the user has no custom banner", async () => { - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; await expect(ACTION_DeleteBanner(taker, {})).rejects.toThrow(); @@ -494,22 +555,20 @@ describe("ACTION_DeleteBanner", () => { // ── Success path ────────────────────────────────────────────────────────── - it("returns {} on success", async () => { - await seedUserWithBanner(userId, "existinghash"); - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + it("uploads a banner then deletes it, removing it from S3", async () => { + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; - const result = await ACTION_DeleteBanner(taker, {}); + const { contentHash } = await ACTION_ChangeBanner(taker, { + "!fileBuffer": ACORN_PNG, + fileMimetype: "image/png", + }); - expect(result).toEqual({}); - }); - - it("clears custom_banner_location to null in the DB", async () => { - await seedUserWithBanner(userId, "existinghash"); - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const cdnPath = GetProfileBannerURL(userId, contentHash); await ACTION_DeleteBanner(taker, {}); expect(await getBannerLocation(userId)).toBeNull(); + await expect(CDNRetrieve(cdnPath)).rejects.toThrow(); }); it("does not touch other users' custom_banner_location", async () => { @@ -517,30 +576,18 @@ describe("ACTION_DeleteBanner", () => { await seedUserWithBanner(other.id, "otherhash"); await seedUserWithBanner(userId, "myhash"); - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "127.0.0.1" }; await ACTION_DeleteBanner(taker, {}); expect(await getBannerLocation(other.id)).toBe("otherhash"); }); - // ── CDN calls ───────────────────────────────────────────────────────────── - - it("calls CDNDelete once with the correct URL", async () => { - await seedUserWithBanner(userId, "existinghash"); - const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; - - await ACTION_DeleteBanner(taker, {}); - - expect(CDNDelete).toHaveBeenCalledOnce(); - expect(CDNDelete).toHaveBeenCalledWith(GetProfileBannerURL(userId, "existinghash")); - }); - // ── Audit log ───────────────────────────────────────────────────────────── it("writes a GOOD action row on success", async () => { await seedUserWithBanner(userId, "existinghash"); - const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + const taker = { acct: { id: userId, username }, ip: "10.0.0.1" }; await ACTION_DeleteBanner(taker, {}); @@ -550,9 +597,9 @@ describe("ACTION_DeleteBanner", () => { .executeTakeFirstOrThrow(); expect(action).toMatchObject({ + ip: "10.0.0.1", kind: "DELETE_BANNER", result: "GOOD", - ip: "10.0.0.1", user_id: userId, }); }); diff --git a/typescript/server/src/actions/change-pfp.ts b/typescript/server/src/actions/change-pfp.ts index a60af0db0..c31922c3e 100644 --- a/typescript/server/src/actions/change-pfp.ts +++ b/typescript/server/src/actions/change-pfp.ts @@ -1,27 +1,45 @@ import { MakeAction } from "#lib/actions/actions"; -import { CDNStoreOrOverwrite } from "#lib/cdn/cdn"; +import { CDNStoreWithMeta } from "#lib/cdn/cdn"; import { GetProfilePictureURL } from "#lib/cdn/url-format"; import DB from "#services/pg/db"; import { HashSHA256 } from "#utils/crypto"; import { ExpectedErr } from "bliss"; +import sharp from "sharp"; + +/** Max dimension (width or height) for stored profile pictures. */ +const PFP_MAX_PX = 256; + +/** + * Resizes any image (including animated GIFs) to at most PFP_MAX_PX × PFP_MAX_PX + * and re-encodes as WebP, preserving all animation frames. + */ +async function resizePfp(buf: Buffer): Promise { + return sharp(buf, { animated: true }) + .resize(PFP_MAX_PX, PFP_MAX_PX, { fit: "inside", withoutEnlargement: true }) + .webp({ quality: 85 }) + .toBuffer(); +} export const ACTION_ChangePfp = MakeAction( "CHANGE_PFP", async (taker, { "!fileBuffer": fileBuffer, fileMimetype }) => { - const contentHash = HashSHA256(fileBuffer); - if ( - fileMimetype === "image/jpeg" || - fileMimetype === "image/png" || - fileMimetype === "image/gif" + fileMimetype !== "image/jpeg" && + fileMimetype !== "image/png" && + fileMimetype !== "image/gif" ) { - await CDNStoreOrOverwrite(GetProfilePictureURL(taker.acct.id, contentHash), fileBuffer); - } else { - // GIF is deliberately not mentioned here - // as it's an easter egg + // GIF is deliberately not mentioned in the error message as it's an easter egg throw new ExpectedErr(400, "Invalid file - only JPG and PNG files are supported."); } + const storedBuffer = await resizePfp(fileBuffer); + const contentHash = HashSHA256(storedBuffer); + + await CDNStoreWithMeta(GetProfilePictureURL(taker.acct.id, contentHash), storedBuffer, { + contentType: "image/webp", + cacheControl: "public, max-age=31536000, immutable", + }); + await DB.updateTable("account") .set({ custom_pfp_location: contentHash }) .where("id", "=", taker.acct.id) diff --git a/typescript/server/src/lib/cdn/cdn.test.ts b/typescript/server/src/lib/cdn/cdn.test.ts index f64985af1..d24e0c68c 100644 --- a/typescript/server/src/lib/cdn/cdn.test.ts +++ b/typescript/server/src/lib/cdn/cdn.test.ts @@ -6,6 +6,7 @@ */ import { ServerConfig } from "#lib/setup/config"; +import { HashSHA256 } from "#utils/crypto"; import { randomUUID } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; @@ -31,7 +32,7 @@ describe("cdn (S3 integration)", () => { const original = Buffer.from([0, 255, 128, 1]); await CDNStoreOrOverwrite(loc, original); const got = await CDNRetrieve(loc); - expect(Buffer.compare(got, original)).toBe(0); + expect(HashSHA256(got)).toBe(HashSHA256(original)); await CDNDelete(loc); }); diff --git a/typescript/server/src/lib/cdn/cdn.ts b/typescript/server/src/lib/cdn/cdn.ts index 089f9e10d..9a0331ea1 100644 --- a/typescript/server/src/lib/cdn/cdn.ts +++ b/typescript/server/src/lib/cdn/cdn.ts @@ -3,7 +3,12 @@ import type { Response } from "express"; import { log } from "#lib/log/log"; import { ServerConfig } from "#lib/setup/config"; -import { DeleteFromS3_PUBLIC, GetObjectFromS3_PUBLIC, PushToS3_PUBLIC } from "./s3"; +import { + DeleteFromS3_PUBLIC, + GetObjectFromS3_PUBLIC, + PushToS3_PUBLIC, + type S3ObjectMeta, +} from "./s3"; /** * Retrieves the bytes at the given CDN location from S3. @@ -36,6 +41,20 @@ export async function CDNStoreOrOverwrite(fileLoc: string, data: string | Buffer await PushToS3_PUBLIC(fileLoc.replace(/^\//u, ""), data); } +/** + * Stores a file at fileLoc with explicit S3 object metadata (ContentType, CacheControl). + * If it already exists, overwrite it. + */ +export async function CDNStoreWithMeta( + fileLoc: string, + data: string | Buffer, + meta: S3ObjectMeta, +): Promise { + log.debug(`Storing or overwriting path ${fileLoc} with metadata.`); + + await PushToS3_PUBLIC(fileLoc.replace(/^\//u, ""), data, meta); +} + /** * Removes a file at this CDN location. */ diff --git a/typescript/server/src/lib/cdn/s3.ts b/typescript/server/src/lib/cdn/s3.ts index 3f091ee4d..d716135a1 100644 --- a/typescript/server/src/lib/cdn/s3.ts +++ b/typescript/server/src/lib/cdn/s3.ts @@ -46,10 +46,15 @@ export function cdnObjectKey_PRIVATE(fileLoc: string): string { return (saveLocPrivate.KEY_PREFIX ?? "") + fileLoc; } +export interface S3ObjectMeta { + cacheControl?: string; + contentType?: string; +} + /** * Pushes a file to the configured S3 Bucket. Overwrites if already exists. */ -export function PushToS3_PUBLIC(path: string, content: string | Buffer) { +export function PushToS3_PUBLIC(path: string, content: string | Buffer, meta?: S3ObjectMeta) { if (path.startsWith("/")) { throw new Error( `no. absolutely not. Trying to do s3 push with a prefix /. this causes all sorts of trouble: ${path}`, @@ -62,6 +67,8 @@ export function PushToS3_PUBLIC(path: string, content: string | Buffer) { Bucket: saveLoc.BUCKET, Key: cdnObjectKey(path), Body: content, + CacheControl: meta?.cacheControl, + ContentType: meta?.contentType, }), ); } diff --git a/typescript/server/src/lib/score-import/framework/pb/rederive-scores.recalc.test.ts b/typescript/server/src/lib/score-import/framework/pb/rederive-scores.recalc.test.ts index 95a32e66f..959fb9460 100644 --- a/typescript/server/src/lib/score-import/framework/pb/rederive-scores.recalc.test.ts +++ b/typescript/server/src/lib/score-import/framework/pb/rederive-scores.recalc.test.ts @@ -577,7 +577,6 @@ describe("rederiveScoresForChart / chart checksum recalc (Postgres)", () => { await rederiveScoresForChart(chartId, log); for (const scoreId of scoreIds) { - // eslint-disable-next-line no-await-in-loop const row = await DB.selectFrom("score") .select(["score.calculated_data"]) .where("score.id", "=", scoreId) diff --git a/typescript/server/src/scripts/backfill-media.ts b/typescript/server/src/scripts/backfill-media.ts new file mode 100644 index 000000000..5fa8c18dd --- /dev/null +++ b/typescript/server/src/scripts/backfill-media.ts @@ -0,0 +1,359 @@ +/** + * backfill-media.ts + * + * One-off script to resize and add cache metadata to all existing profile + * pictures and banners in S3. Reads each user's current object, runs it + * through the same resize/WebP pipeline introduced by the profile image + * optimisation, stores the result under a new content-addressed key (with + * immutable cache headers), and updates the DB row. + * + * Old S3 objects are NOT deleted - they remain as orphans in the bucket. + * Remove them separately once you are satisfied the migration is complete, + * for example with: + * aws s3 rm --recursive s3://BUCKET/users/ --exclude pfp --dryrun + * + * The script is safe to re-run: if the existing object is already a small + * WebP its hash will be unchanged after reprocessing and the DB update is + * skipped. + * + * Usage: + * bun run src/scripts/backfill-media.ts \ + * --pg-url postgresql://tachi:tachi@localhost/tachi \ + * --endpoint http://localhost:9000 \ + * --access-key-id KEY \ + * --secret-access-key SECRET \ + * --bucket tachi-public \ + * [--region us-east-1] \ + * [--key-prefix some/prefix/] \ + * [--dry-run] + * + * Alternatively, the S3 flags are read from the matching + * TACHI_CDN_SAVE_LOCATION_* environment variables when not supplied on the + * command line. + */ + +import type { Readable } from "node:stream"; +import type { Database } from "tachi-db"; + +import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { Kysely, PostgresDialect } from "kysely"; +import crypto from "node:crypto"; +import { buffer as streamToBuffer } from "node:stream/consumers"; +import { parseArgs } from "node:util"; +import pg from "pg"; +import sharp from "sharp"; + +// ─── Constants (must match change-pfp.ts / change-banner.ts) ───────────────── + +const PFP_MAX_PX = 256; +const BANNER_MAX_WIDTH = 1920; +const BANNER_MAX_HEIGHT = 1080; +const CACHE_CONTROL_IMMUTABLE = "public, max-age=31536000, immutable"; + +// ─── CLI args ───────────────────────────────────────────────────────────────── + +function printHelp(): void { + console.log(` +backfill-media — resize and cache-header all existing profile pictures/banners + + --pg-url Postgres connection string [TACHI_PG_URL] + --endpoint S3 endpoint [TACHI_CDN_SAVE_LOCATION_ENDPOINT] + --access-key-id S3 access key ID [TACHI_CDN_SAVE_LOCATION_ACCESS_KEY_ID] + --secret-access-key S3 secret access key [TACHI_CDN_SAVE_LOCATION_SECRET_ACCESS_KEY] + --bucket S3 bucket name [TACHI_CDN_SAVE_LOCATION_BUCKET] + --region S3 region (default: us-east-1) [TACHI_CDN_SAVE_LOCATION_REGION] + --key-prefix Key prefix in the bucket [TACHI_CDN_SAVE_LOCATION_KEY_PREFIX] + --dry-run Log what would change without writing anything + -h, --help Show this help +`); +} + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + "pg-url": { type: "string" }, + endpoint: { type: "string" }, + "access-key-id": { type: "string" }, + "secret-access-key": { type: "string" }, + bucket: { type: "string" }, + region: { type: "string" }, + "key-prefix": { type: "string" }, + "dry-run": { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, +}); + +if (values.help) { + printHelp(); + process.exit(0); +} + +function envOr(cliValue: string | undefined, envVar: string): string | undefined { + return cliValue ?? process.env[envVar]; +} + +function requireArg(value: string | undefined, name: string, envVar: string): string { + if (!value) { + console.error(`backfill-media: --${name} (or ${envVar}) is required.`); + printHelp(); + process.exit(1); + } + return value; +} + +const pgUrl = requireArg(envOr(values["pg-url"], "TACHI_PG_URL"), "pg-url", "TACHI_PG_URL"); + +const s3Endpoint = requireArg( + envOr(values.endpoint, "TACHI_CDN_SAVE_LOCATION_ENDPOINT"), + "endpoint", + "TACHI_CDN_SAVE_LOCATION_ENDPOINT", +); + +const s3AccessKeyId = requireArg( + envOr(values["access-key-id"], "TACHI_CDN_SAVE_LOCATION_ACCESS_KEY_ID"), + "access-key-id", + "TACHI_CDN_SAVE_LOCATION_ACCESS_KEY_ID", +); + +const s3SecretAccessKey = requireArg( + envOr(values["secret-access-key"], "TACHI_CDN_SAVE_LOCATION_SECRET_ACCESS_KEY"), + "secret-access-key", + "TACHI_CDN_SAVE_LOCATION_SECRET_ACCESS_KEY", +); + +const s3Bucket = requireArg( + envOr(values.bucket, "TACHI_CDN_SAVE_LOCATION_BUCKET"), + "bucket", + "TACHI_CDN_SAVE_LOCATION_BUCKET", +); + +const s3Region = envOr(values.region, "TACHI_CDN_SAVE_LOCATION_REGION") ?? "us-east-1"; +const keyPrefix = envOr(values["key-prefix"], "TACHI_CDN_SAVE_LOCATION_KEY_PREFIX") ?? ""; +const isDryRun = values["dry-run"] ?? false; + +if (isDryRun) { + console.log("[backfill-media] DRY RUN — no changes will be written."); +} + +// ─── S3 + DB clients ────────────────────────────────────────────────────────── + +const s3 = new S3Client({ + endpoint: s3Endpoint, + region: s3Region, + credentials: { accessKeyId: s3AccessKeyId, secretAccessKey: s3SecretAccessKey }, + forcePathStyle: true, +}); + +const pool = new pg.Pool({ connectionString: pgUrl }); +const DB = new Kysely({ dialect: new PostgresDialect({ pool }) }); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function s3Key(cdnPath: string): string { + // cdnPath is always /users/... — strip leading slash then prepend prefix. + return `${keyPrefix}${cdnPath.replace(/^\//u, "")}`; +} + +function hashBuffer(buf: Buffer): string { + return crypto.createHash("sha256").update(buf).digest("hex"); +} + +async function downloadFromS3(cdnPath: string): Promise { + try { + const response = await s3.send( + new GetObjectCommand({ Bucket: s3Bucket, Key: s3Key(cdnPath) }), + ); + + if (!response.Body) { + return null; + } + + return streamToBuffer(response.Body as Readable); + } catch (err: unknown) { + // Object missing from S3 — the DB row is a dangling reference. + if ( + err instanceof Error && + (err.name === "NoSuchKey" || err.message.includes("NoSuchKey")) + ) { + return null; + } + throw err; + } +} + +async function uploadToS3(cdnPath: string, body: Buffer, contentType: string): Promise { + await s3.send( + new PutObjectCommand({ + Bucket: s3Bucket, + Key: s3Key(cdnPath), + Body: body, + ContentType: contentType, + CacheControl: CACHE_CONTROL_IMMUTABLE, + }), + ); +} + +async function resizePfp(buf: Buffer): Promise { + return sharp(buf, { animated: true }) + .resize(PFP_MAX_PX, PFP_MAX_PX, { fit: "inside", withoutEnlargement: true }) + .webp({ quality: 85 }) + .toBuffer(); +} + +async function resizeBanner(buf: Buffer): Promise { + return sharp(buf, { animated: true }) + .resize(BANNER_MAX_WIDTH, BANNER_MAX_HEIGHT, { fit: "inside", withoutEnlargement: true }) + .webp({ quality: 85 }) + .toBuffer(); +} + +// ─── Core migration logic ───────────────────────────────────────────────────── + +interface Stats { + processed: number; + skipped: number; + missing: number; + errors: number; +} + +async function migrateMedia( + userId: number, + username: string, + currentHash: string, + cdnPathFn: (id: number, hash: string) => string, + resizeFn: (buf: Buffer) => Promise, + dbColumn: "custom_banner_location" | "custom_pfp_location", + label: string, + stats: Stats, +): Promise { + const currentCdnPath = cdnPathFn(userId, currentHash); + const logPrefix = ` [${username}/${label}]`; + + const original = await downloadFromS3(currentCdnPath); + + if (!original) { + console.warn(`${logPrefix} S3 object missing — skipping (dangling DB reference).`); + stats.missing++; + return; + } + + let resized: Buffer; + + try { + resized = await resizeFn(original); + } catch (err: unknown) { + console.error(`${logPrefix} sharp failed to process — skipping.`, err); + stats.errors++; + return; + } + + const newHash = hashBuffer(resized); + + if (newHash === currentHash) { + console.log(`${logPrefix} already optimal (hash unchanged) — skipping.`); + stats.skipped++; + return; + } + + const newCdnPath = cdnPathFn(userId, newHash); + const sizeBefore = original.length; + const sizeAfter = resized.length; + const pct = Math.round((1 - sizeAfter / sizeBefore) * 100); + + console.log( + `${logPrefix} ${(sizeBefore / 1024).toFixed(1)} KB → ${(sizeAfter / 1024).toFixed(1)} KB (${pct}% smaller, image/webp)`, + ); + + if (!isDryRun) { + await uploadToS3(newCdnPath, resized, "image/webp"); + + await DB.updateTable("account") + .set({ [dbColumn]: newHash }) + .where("id", "=", userId) + .execute(); + } + + stats.processed++; +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + console.log(`[backfill-media] Connected to ${pgUrl}.`); + console.log(`[backfill-media] S3 endpoint: ${s3Endpoint}, bucket: ${s3Bucket}`); + + const users = await DB.selectFrom("account") + .select(["id", "username", "custom_pfp_location", "custom_banner_location"]) + .where((eb) => + eb.or([ + eb("custom_pfp_location", "is not", null), + eb("custom_banner_location", "is not", null), + ]), + ) + .orderBy("id", "asc") + .execute(); + + console.log( + `[backfill-media] Found ${users.length} users with at least one custom media object.`, + ); + + const pfpStats: Stats = { processed: 0, skipped: 0, missing: 0, errors: 0 }; + const bannerStats: Stats = { processed: 0, skipped: 0, missing: 0, errors: 0 }; + + for (const user of users) { + if (user.custom_pfp_location) { + await migrateMedia( + user.id, + user.username, + user.custom_pfp_location, + (id, hash) => `/users/${id}/pfp-${hash}`, + resizePfp, + "custom_pfp_location", + "pfp", + pfpStats, + ); + } + + if (user.custom_banner_location) { + await migrateMedia( + user.id, + user.username, + user.custom_banner_location, + (id, hash) => `/users/${id}/banner-${hash}`, + resizeBanner, + "custom_banner_location", + "banner", + bannerStats, + ); + } + } + + console.log(` +[backfill-media] Done. + + Profile pictures + Resized and re-uploaded : ${pfpStats.processed} + Already optimal (skip) : ${pfpStats.skipped} + Missing from S3 : ${pfpStats.missing} + Errors : ${pfpStats.errors} + + Banners + Resized and re-uploaded : ${bannerStats.processed} + Already optimal (skip) : ${bannerStats.skipped} + Missing from S3 : ${bannerStats.missing} + Errors : ${bannerStats.errors} +`); + + if (!isDryRun && pfpStats.errors + bannerStats.errors > 0) { + console.warn( + "[backfill-media] Some objects failed to process. Re-run the script to retry them.", + ); + } + + await pool.end(); +} + +await main().catch((err: unknown) => { + console.error("[backfill-media] Fatal error:", err); + process.exit(1); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/banner/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/banner/router.test.ts index ec66288de..e61256ff7 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/banner/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/banner/router.test.ts @@ -1,5 +1,5 @@ import { seedApiToken } from "#actions/test-utils/api-tokens"; -import { CDNStoreOrOverwrite } from "#lib/cdn/cdn"; +import { CDNRetrieve, CDNStoreOrOverwrite } from "#lib/cdn/cdn"; import { GetProfileBannerURL } from "#lib/cdn/url-format"; import DB from "#services/pg/db"; import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; @@ -56,9 +56,14 @@ describe("PUT /api/v1/users/:userID/banner", () => { expect(res.status).toBe(200); - const get = await mockApi.get(res.body.body.get).redirects(1); + const { custom_banner_location } = await DB.selectFrom("account") + .select("custom_banner_location") + .where("id", "=", 1) + .executeTakeFirstOrThrow(); - expect(Buffer.isBuffer(get.body) ? get.body : Buffer.from(get.body)).toStrictEqual(img); + const stored = await CDNRetrieve(GetProfileBannerURL(1, custom_banner_location!)); + + expect(stored.length).toBeLessThan(img.length); }); it("stores a banner when the user already had a custom banner", async () => { @@ -76,8 +81,13 @@ describe("PUT /api/v1/users/:userID/banner", () => { expect(res.status).toBe(200); - const get = await mockApi.get(res.body.body.get).redirects(1); + const { custom_banner_location } = await DB.selectFrom("account") + .select("custom_banner_location") + .where("id", "=", 1) + .executeTakeFirstOrThrow(); - expect(Buffer.isBuffer(get.body) ? get.body : Buffer.from(get.body)).toStrictEqual(img); + const stored = await CDNRetrieve(GetProfileBannerURL(1, custom_banner_location!)); + + expect(stored.length).toBeLessThan(img.length); }); }); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts index 43c980e39..d8878b417 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts @@ -75,6 +75,11 @@ API_V1_ROUTER.rawAdd( API_V1_ROUTER.add("GET /users/:userID/banner", withRequestedUser, ({ ctx, res }) => { const { requestedUser: user } = ctx; + // The redirect target is content-addressed (hash in path), so the CDN + // object itself is immutable. Cache the userId→CDN-URL mapping for 1 hour + // so browsers don't hit the API on every page load. + res.setHeader("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400"); + if (!user.customBannerLocation) { res.setHeader("Content-Type", "image/png"); CDNRedirect(res, "/users/default/banner"); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.test.ts index 375570d2e..26e64a115 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.test.ts @@ -1,5 +1,5 @@ import { seedApiToken } from "#actions/test-utils/api-tokens"; -import { CDNStoreOrOverwrite } from "#lib/cdn/cdn"; +import { CDNRetrieve, CDNStoreOrOverwrite } from "#lib/cdn/cdn"; import { GetProfilePictureURL } from "#lib/cdn/url-format"; import DB from "#services/pg/db"; import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; @@ -55,9 +55,14 @@ describe("PUT /api/v1/users/:userID/pfp", () => { expect(res.status).toBe(200); - const get = await mockApi.get(res.body.body.get).redirects(1); + const { custom_pfp_location } = await DB.selectFrom("account") + .select("custom_pfp_location") + .where("id", "=", 1) + .executeTakeFirstOrThrow(); - expect(Buffer.isBuffer(get.body) ? get.body : Buffer.from(get.body)).toStrictEqual(img); + const stored = await CDNRetrieve(GetProfilePictureURL(1, custom_pfp_location!)); + + expect(stored.length).toBeLessThan(img.length); }); it("stores a profile picture when the user already had a custom pfp", async () => { @@ -75,8 +80,13 @@ describe("PUT /api/v1/users/:userID/pfp", () => { expect(res.status).toBe(200); - const get = await mockApi.get(res.body.body.get).redirects(1); + const { custom_pfp_location } = await DB.selectFrom("account") + .select("custom_pfp_location") + .where("id", "=", 1) + .executeTakeFirstOrThrow(); - expect(Buffer.isBuffer(get.body) ? get.body : Buffer.from(get.body)).toStrictEqual(img); + const stored = await CDNRetrieve(GetProfilePictureURL(1, custom_pfp_location!)); + + expect(stored.length).toBeLessThan(img.length); }); }); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts index c1aa0b0ad..7548a7844 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts @@ -78,6 +78,11 @@ API_V1_ROUTER.add("GET /users/:userID/pfp", withRequestedUser, ({ ctx, res }) => log.debug(user, "User Info for /:userID/pfp request is "); + // The redirect target is content-addressed (hash in path), so the CDN + // object itself is immutable. Cache the userId→CDN-URL mapping for 1 hour + // so browsers don't hit the API on every page load. + res.setHeader("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400"); + if (!user.customPfpLocation) { res.setHeader("Content-Type", "image/png"); CDNRedirect(res, "/users/default/pfp"); diff --git a/typescript/server/vitest.config.ts b/typescript/server/vitest.config.ts index 5a046e57d..60c2fe972 100644 --- a/typescript/server/vitest.config.ts +++ b/typescript/server/vitest.config.ts @@ -120,20 +120,14 @@ export default defineConfig({ extends: true, test: { name: "default", - exclude: [ - "src/actions/bms-table-sync.test.ts", - "src/actions/change-pfp.test.ts", - ], + exclude: ["src/actions/bms-table-sync.test.ts"], }, }, { extends: true, test: { name: "isolated", - include: [ - "src/actions/bms-table-sync.test.ts", - "src/actions/change-pfp.test.ts", - ], + include: ["src/actions/bms-table-sync.test.ts"], poolOptions: { threads: { isolate: true,