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:
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