corpus

Log | Files | Refs | README | LICENSE

commit b7a2afe9432db697e6ef4b7838e83490a335583e
parent 4786b57869b935b953a4a2f4718b9e26d95341e4
Author: mtmn <miro@haravara.org>
Date:   Mon,  8 Jun 2026 23:27:09 +0200

feat(cache): generate presigned URLs for covers

Diffstat:
Mflake.nix | 2+-
Mpackage.json | 59++++++++++++++++++++++++++++++-----------------------------
Mpnpm-lock.yaml | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/Cover.purs | 12+++++++-----
Msrc/S3.js | 14++++++++++++++
Msrc/S3.purs | 11+++++++++++
6 files changed, 124 insertions(+), 35 deletions(-)

diff --git a/flake.nix b/flake.nix @@ -58,7 +58,7 @@ name = "corpus-pnpm-source"; }; pname = "corpus"; - hash = "sha256-7xgw8iQ1d69rQCiiyuRXMk5phMg2df5NHfzKGDboxak="; + hash = "sha256-H8XTwSL9ura4VYTrBQwqtxt881TDBXOZyoUzAHDvep4="; fetcherVersion = 3; }; diff --git a/package.json b/package.json @@ -1,31 +1,32 @@ { - "name": "corpus", - "version": "2.18.1", - "description": "ListenBrainz and Last.fm frontend", - "main": "index.js", - "type": "module", - "scripts": { - "build": "spago build && esbuild output/Main/index.js --bundle --platform=node --format=esm --footer:js='main();' --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:@duckdb/node-api --external:@aws-sdk/client-s3 --external:prom-client --external:sharp --external:uuid && elm make src/Client.elm --output=client.js", - "release": "spago build && purs-backend-es build && esbuild output-es/Main/index.js --bundle --platform=node --format=esm --footer:js='main();' --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:@duckdb/node-api --external:@aws-sdk/client-s3 --external:prom-client --external:sharp --external:uuid && elm make src/Client.elm --optimize --output=client.js && uglifyjs client.js --compress \"pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe\" | uglifyjs --mangle --output client.js", - "test": "spago test", - "tidy": "purs-tidy format-in-place src/**/*.purs" - }, - "devDependencies": { - "esbuild": "^0.28.0", - "purescript-language-server": "^0.18.5", - "purescript-psa": "^0.9.0", - "purs-backend-es": "^1.4.3", - "purs-tidy": "^0.11.1", - "spago": "^1.0.4", - "uglify-js": "^3.19.3", - "whine": "^0.0.33" - }, - "dependencies": { - "@aws-sdk/client-s3": "^3.1061.0", - "@duckdb/node-api": "1.5.2-r.2", - "dotenv": "^17.4.2", - "prom-client": "^15.1.3", - "sharp": "^0.34.5", - "uuid": "^14.0.0" - } + "name": "corpus", + "version": "2.18.1", + "description": "ListenBrainz and Last.fm frontend", + "main": "index.js", + "type": "module", + "scripts": { + "build": "spago build && esbuild output/Main/index.js --bundle --platform=node --format=esm --footer:js='main();' --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:@duckdb/node-api --external:@aws-sdk/client-s3 --external:@aws-sdk/s3-request-presigner --external:prom-client --external:sharp --external:uuid && elm make src/Client.elm --output=client.js", + "release": "spago build && purs-backend-es build && esbuild output-es/Main/index.js --bundle --platform=node --format=esm --footer:js='main();' --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:@duckdb/node-api --external:@aws-sdk/client-s3 --external:@aws-sdk/s3-request-presigner --external:prom-client --external:sharp --external:uuid && elm make src/Client.elm --optimize --output=client.js && uglifyjs client.js --compress \"pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe\" | uglifyjs --mangle --output client.js", + "test": "spago test", + "tidy": "purs-tidy format-in-place src/**/*.purs" + }, + "devDependencies": { + "esbuild": "^0.28.0", + "purescript-language-server": "^0.18.5", + "purescript-psa": "^0.9.0", + "purs-backend-es": "^1.4.3", + "purs-tidy": "^0.11.1", + "spago": "^1.0.4", + "uglify-js": "^3.19.3", + "whine": "^0.0.33" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1061.0", + "@aws-sdk/s3-request-presigner": "^3.1063.0", + "@duckdb/node-api": "1.5.2-r.2", + "dotenv": "^17.4.2", + "prom-client": "^15.1.3", + "sharp": "^0.34.5", + "uuid": "^14.0.0" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@aws-sdk/client-s3': specifier: ^3.1061.0 version: 3.1061.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.1063.0 + version: 3.1063.0 '@duckdb/node-api': specifier: 1.5.2-r.2 version: 1.5.2-r.2 @@ -89,6 +92,10 @@ packages: resolution: {integrity: sha512-r8o4h2K7j6P9ngno+8ei0aK0U/4JwDb7A2fMMxGVoSqDN8AFlIzSDeZHME9LcVLR2codyhtr1WAAg+/nmkeeMA==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.18': + resolution: {integrity: sha512-JDYCPI0j7zGrzXTDFsLB346cxss7J/AxH7+O0MzWlqppJBEyB9Qe6TQXRL6iwLUo/xZkNv9KFmBL2hqElmwW0g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.43': resolution: {integrity: sha512-g0XVQKzaA/4cq1vz1IvCQwYM+1Pkv01J9yHDpCTXekVuGZRDEz0wqBQ1AuYTq7FM6uik4uBGH8Tb5d9YvgeA7g==} engines: {node: '>=20.0.0'} @@ -149,10 +156,18 @@ packages: resolution: {integrity: sha512-Fpri1/PXKMKveORZ7E00VLTlWS5DkfZkW70PUE+bOnpWpAeHAQLoiDHhkzN3kNWbbSsGg64+IZYiq/EZgME3Mg==} engines: {node: '>=20.0.0'} + '@aws-sdk/s3-request-presigner@3.1063.0': + resolution: {integrity: sha512-uYDuWEbBYz/DfS0sEbDA3BeBCNNaLiE+dmblIRrmU02bhoqsotJb3qWfx0+I3JlukC7Vk8wRuyRgjZzq1FyXGA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.31': resolution: {integrity: sha512-Kn2up9SlG1KC6wRtwf0d7waTGF6rvp9DxYqB54x6UCKdQ6kyaXCqHL4WGb5vUJga5kS8FxnjhY0LqM28aMvnNQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.32': + resolution: {integrity: sha512-llvApLcsWtmRFhG2wT3WIp1CmDeRaIYutqty1ZZXoMzK7TiJ6MOLOimk9eXUS8PwgG4ew4pa4QAbt0lfhn++1w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1060.0': resolution: {integrity: sha512-6NZaMKkFhpaNiwLpHi1sZaYjidL/lCJE6ME6NxwA8gv9vQna+Kr0j4OFwVoz6tANRWM3WbGz6jiPsGX/Vkjwow==} engines: {node: '>=20.0.0'} @@ -161,6 +176,10 @@ packages: resolution: {integrity: sha512-992QrTO7G9qCvKD0fx1rMlqcL14plUcRAbwmqqYVsuF3GrqcvlAL9qxR+baMafarEZ+l7DUQ5lCMmt5mbMhF7g==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.11': + resolution: {integrity: sha512-YjS0qFuECClRh4qhEyW8XagW0fwEPBeZ1cfsW/gU73Kh/ExFILxbzxOfPCmzF/2DwEvhvsHYt0b0qnvStwKYrg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.965.5': resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} engines: {node: '>=20.0.0'} @@ -169,6 +188,10 @@ packages: resolution: {integrity: sha512-hpsCXCOI436kxWpjtRuIHVvuPP81MOw8f18jzfZeg+UOiiOvlqWcmWChzEhJEu16cOC6+ku4ncBN+7rdt+DZ9g==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.28': + resolution: {integrity: sha512-lI/l3c/vPvsxmspzV63NfS3x9q4CkMmdhJy4QiM+NThAufVkDvi/PZZQ6xETnICL0UD7jI808pY83gllf86RFg==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} @@ -1089,6 +1112,17 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/core@3.974.18': + dependencies: + '@aws-sdk/types': 3.973.11 + '@aws-sdk/xml-builder': 3.972.28 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.6 + '@smithy/signature-v4': 5.4.6 + '@smithy/types': 4.14.3 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.43': dependencies: '@aws-sdk/core': 3.974.17 @@ -1220,6 +1254,15 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 + '@aws-sdk/s3-request-presigner@3.1063.0': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/signature-v4-multi-region': 3.996.32 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.31': dependencies: '@aws-sdk/types': 3.973.10 @@ -1227,6 +1270,13 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.32': + dependencies: + '@aws-sdk/types': 3.973.11 + '@smithy/signature-v4': 5.4.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1060.0': dependencies: '@aws-sdk/core': 3.974.17 @@ -1241,6 +1291,11 @@ snapshots: '@smithy/types': 4.14.3 tslib: 2.8.1 + '@aws-sdk/types@3.973.11': + dependencies: + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/util-locate-window@3.965.5': dependencies: tslib: 2.8.1 @@ -1251,6 +1306,12 @@ snapshots: fast-xml-parser: 5.7.3 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.28': + dependencies: + '@smithy/types': 4.14.3 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': {} '@duckdb/node-api@1.5.2-r.2': diff --git a/src/Cover.purs b/src/Cover.purs @@ -11,7 +11,7 @@ import Prelude import Control.Alt ((<|>)) import Config (UserConfig, s3ConfigFromUser) -import S3 (existsInS3, getS3Url, uploadToS3) +import S3 (existsInS3, getPresignedUrl, uploadToS3) import Data.Array ((!!), find) import Data.Either (Either(..), fromRight') import Data.Foldable (foldM) @@ -181,7 +181,7 @@ serveCover serveNotFound cfg slug url res = do if cached then do Log.info $ "Serving " <> name <> " cover from cache: " <> s3Key liftEffect $ Metrics.incCoverRequest slug name "s3_hit" - liftEffect $ serveS3Redirect s3cfg s3Key res + serveS3Redirect s3cfg s3Key res pure true else do mUrl <- findUrl @@ -202,9 +202,11 @@ serveCover serveNotFound cfg slug url res = do Left _ -> false serveS3Redirect s3cfg s3Key response = do - setStatusCode 302 response - setHeader "Location" (getS3Url s3cfg s3Key) (toOutgoingMessage response) - end (toWriteable (toOutgoingMessage response)) + presignedUrl <- getPresignedUrl s3cfg s3Key + liftEffect $ do + setStatusCode 302 response + setHeader "Location" presignedUrl (toOutgoingMessage response) + end (toWriteable (toOutgoingMessage response)) -- Redirect the client immediately to the upstream URL, then fetch+convert+cache in background. -- This avoids blocking the response on AVIF conversion (which can take hundreds of ms). diff --git a/src/S3.js b/src/S3.js @@ -2,7 +2,9 @@ import { S3Client, PutObjectCommand, HeadObjectCommand, + GetObjectCommand, } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; const makeClient = (cfg) => new S3Client({ @@ -52,6 +54,18 @@ export const existsInS3Impl = (cfg, key, cb) => () => { }); }; +export const getPresignedUrlImpl = (cfg, key, cb) => () => { + const client = makeClient(cfg); + const command = new GetObjectCommand({ + Bucket: cfg.bucket, + Key: key, + }); + + getSignedUrl(client, command, { expiresIn: 86400 }) + .then((url) => cb(null)(url)()) + .catch((err) => cb(err)("")()); +}; + export const getS3UrlImpl = (cfg, key) => { const endpoint = cfg.endpointUrl || ""; const bucket = cfg.bucket || ""; diff --git a/src/S3.purs b/src/S3.purs @@ -37,6 +37,9 @@ foreign import uploadToS3Impl foreign import existsInS3Impl :: Fn3 S3ConfigJs String (Nullable Error -> Boolean -> Effect Unit) (Effect Unit) +foreign import getPresignedUrlImpl + :: Fn3 S3ConfigJs String (Nullable Error -> String -> Effect Unit) (Effect Unit) + foreign import getS3UrlImpl :: Fn2 S3ConfigJs String String uploadToS3 :: S3Config -> String -> Buffer -> String -> Aff Unit @@ -55,5 +58,13 @@ existsInS3 cfg key = makeAff \cb -> do Nothing -> cb (Right exists) pure nonCanceler +getPresignedUrl :: S3Config -> String -> Aff String +getPresignedUrl cfg key = makeAff \cb -> do + runFn3 getPresignedUrlImpl (toJs cfg) key \err url -> + case toMaybe err of + Just e -> cb (Left e) + Nothing -> cb (Right url) + pure nonCanceler + getS3Url :: S3Config -> String -> String getS3Url cfg key = runFn2 getS3UrlImpl (toJs cfg) key