Merge branch 'benpai-s3-migrate' into 'main'

Benpai s3 migrate

See merge request PronounsPage/PronounsPage!635
This commit is contained in:
Andrea Vos 2025-08-17 08:56:01 +00:00
commit 714b99413d
11 changed files with 132 additions and 60 deletions

View File

@ -23,8 +23,14 @@ DISCORD_SECRET=
AWS_REGION=eu-west-1 AWS_REGION=eu-west-1
AWS_KEY= AWS_KEY=
AWS_SECRET= AWS_SECRET=
AWS_S3_BUCKET=
AWS_CLOUDFRONT_ID= S3_PROVIDER=
S3_REGION=
S3_KEY=
S3_SECRET=
S3_BUCKET=
S3_ENDPOINT=
S3_PUBLIC_ACCESS=
NARAKEET_API_KEY= NARAKEET_API_KEY=

View File

@ -0,0 +1,51 @@
-- Up
UPDATE users
SET avatarSource = REPLACE(
avatarSource,
'https://dclu0bpcdglik.cloudfront.net/',
'https://cdn.pronouns.page/'
)
WHERE avatarSource LIKE 'https://dclu0bpcdglik.cloudfront.net/%';
UPDATE profiles
SET card = REPLACE(
card,
'https://pronouns-page.s3.eu-west-1.amazonaws.com/',
'https://cdn.pronouns.page/'
)
WHERE card LIKE 'https://pronouns-page.s3.eu-west-1.amazonaws.com/%';
UPDATE profiles
SET cardDark = REPLACE(
cardDark,
'https://pronouns-page.s3.eu-west-1.amazonaws.com/',
'https://cdn.pronouns.page/'
)
WHERE cardDark LIKE 'https://pronouns-page.s3.eu-west-1.amazonaws.com/%';
-- Down
UPDATE users
SET avatarSource = REPLACE(
avatarSource,
'https://cdn.pronouns.page/',
'https://dclu0bpcdglik.cloudfront.net/'
)
WHERE avatarSource LIKE 'https://cdn.pronouns.page/%';
UPDATE profiles
SET card = REPLACE(
card,
'https://cdn.pronouns.page/',
'https://pronouns-page.s3.eu-west-1.amazonaws.com/'
)
WHERE card LIKE 'https://cdn.pronouns.page/%';
UPDATE profiles
SET cardDark = REPLACE(
cardDark,
'https://cdn.pronouns.page/',
'https://pronouns-page.s3.eu-west-1.amazonaws.com/'
)
WHERE cardDark LIKE 'https://cdn.pronouns.page/%';

View File

@ -1,11 +0,0 @@
export const awsConfig = {
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_KEY!,
secretAccessKey: process.env.AWS_SECRET!,
},
};
export const awsParams = {
Bucket: process.env.AWS_S3_BUCKET!,
};

View File

@ -1,21 +1,18 @@
import './setup.ts'; import './setup.ts';
import { S3 } from '@aws-sdk/client-s3';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import Pageres from 'pageres'; import Pageres from 'pageres';
import type { Screenshot } from 'pageres'; import type { Screenshot } from 'pageres';
import { ulid } from 'ulid'; import { ulid } from 'ulid';
import { awsConfig, awsParams } from './aws.ts';
import dbConnection from './db.ts'; import dbConnection from './db.ts';
import type { Database } from './db.ts'; import type { Database } from './db.ts';
import isHighLoadTime from './overload.js'; import isHighLoadTime from './overload.js';
import { s3, s3Config, s3BucketParams } from '~/server/cloudServices.ts';
import jwt from '~/server/jwt.ts'; import jwt from '~/server/jwt.ts';
import { getLocaleUrls } from '~/src/domain.ts'; import { getLocaleUrls } from '~/src/domain.ts';
const s3 = new S3(awsConfig);
const urlBases: Record<string, string> = {}; const urlBases: Record<string, string> = {};
for (const [locale, url] of Object.entries(getLocaleUrls(process.env.NUXT_PUBLIC_DOMAIN_BASE))) { for (const [locale, url] of Object.entries(getLocaleUrls(process.env.NUXT_PUBLIC_DOMAIN_BASE))) {
@ -109,13 +106,12 @@ const shoot = async (db: Database, mode: 'light' | 'dark'): Promise<void> => {
Body: buffer, Body: buffer,
ContentType: 'image/png', ContentType: 'image/png',
ACL: 'public-read', ACL: 'public-read',
...awsParams, ...s3BucketParams,
}); });
await db.get(` await db.get(`
UPDATE profiles UPDATE profiles
SET ${mode === 'dark' ? 'cardDark' : 'card' SET ${mode === 'dark' ? 'cardDark' : 'card'}='${s3Config.publicAccess}/${key}'
}='https://${awsParams.Bucket}.s3.${awsConfig.region}.amazonaws.com/${key}'
WHERE id='${id}'`); WHERE id='${id}'`);
} }
}; };

View File

@ -1,9 +1,8 @@
import './setup.ts'; import './setup.ts';
import { S3 } from '@aws-sdk/client-s3';
import type { ListObjectsV2Output, ObjectIdentifier, _Object } from '@aws-sdk/client-s3'; import type { ListObjectsV2Output, ObjectIdentifier, _Object } from '@aws-sdk/client-s3';
import { awsConfig, awsParams } from './aws.ts'; import { s3, s3BucketParams, s3Config } from './cloudServices.ts';
import dbConnection from './db.ts'; import dbConnection from './db.ts';
import type { Profile } from '~/src/profile.ts'; import type { Profile } from '~/src/profile.ts';
@ -89,7 +88,7 @@ async function cleanup(): Promise<void> {
WHERE card is not null AND card != '' WHERE card is not null AND card != ''
`)) { `)) {
cards[row.card!.match( cards[row.card!.match(
/(https:\/\/pronouns-page.s3.eu-west-1.amazonaws.com\/card\/[^/]+\/.+-([^-]+)\.png)/, `${s3Config.publicAccess}/card/[^/]+/.+-([^-]+)\.png`,
)![1]] = true; )![1]] = true;
} }
for (const row of await db.all(` for (const row of await db.all(`
@ -97,8 +96,8 @@ async function cleanup(): Promise<void> {
FROM profiles FROM profiles
WHERE cardDark is not null AND cardDark != '' WHERE cardDark is not null AND cardDark != ''
`)) { `)) {
const m = row.cardDark.match( const m = row.cardDark!.match(
/https:\/\/pronouns-page.s3.eu-west-1.amazonaws.com\/card\/[^/]+\/.+-([^-]+)-dark\.png/, `${s3Config.publicAccess}/card/[^/]+/.+-([^-]+)-dark\.png`,
); );
if (!m) { if (!m) {
console.error(row.cardDark); console.error(row.cardDark);
@ -114,7 +113,6 @@ async function cleanup(): Promise<void> {
validateIds('Cards', cards, 50_000); validateIds('Cards', cards, 50_000);
console.log('--- Cleaning up S3: Images ---'); console.log('--- Cleaning up S3: Images ---');
const s3 = new S3(awsConfig);
let overall = 0; let overall = 0;
let fresh = 0; let fresh = 0;
let removed = 0; let removed = 0;
@ -130,7 +128,7 @@ async function cleanup(): Promise<void> {
Prefix: 'images/', Prefix: 'images/',
MaxKeys: chunkSize, MaxKeys: chunkSize,
ContinuationToken: continuationToken, ContinuationToken: continuationToken,
...awsParams, ...s3BucketParams,
}); });
if (!objects.Contents) { if (!objects.Contents) {
break; break;
@ -183,7 +181,7 @@ async function cleanup(): Promise<void> {
Delete: { Delete: {
Objects: toRemove, Objects: toRemove,
}, },
...awsParams, ...s3BucketParams,
}); });
} }
@ -201,7 +199,7 @@ async function cleanup(): Promise<void> {
Prefix: 'card/', Prefix: 'card/',
MaxKeys: chunkSize, MaxKeys: chunkSize,
ContinuationToken: continuationToken, ContinuationToken: continuationToken,
...awsParams, ...s3BucketParams,
}); });
if (!objects.Contents) { if (!objects.Contents) {
break; break;
@ -237,7 +235,7 @@ async function cleanup(): Promise<void> {
Delete: { Delete: {
Objects: toRemove, Objects: toRemove,
}, },
...awsParams, ...s3BucketParams,
}); });
} }

41
server/cloudServices.ts Normal file
View File

@ -0,0 +1,41 @@
import { S3 } from '@aws-sdk/client-s3';
import type { S3ClientConfig } from '@aws-sdk/client-s3';
export enum SupportedProviders {
AWS = 'aws',
CLOUDFLARE = 'cloudflare',
LOCAL = 'local',
}
export interface S3Config extends S3ClientConfig {
provider: SupportedProviders;
/**
* url under which the stored items are publicly reachable
*/
publicAccess?: string;
}
export const s3Config: S3Config = {
provider: process.env.S3_PROVIDER as SupportedProviders || SupportedProviders.AWS,
region: process.env.S3_REGION || undefined,
credentials: {
accessKeyId: process.env.S3_KEY!,
secretAccessKey: process.env.S3_SECRET!,
},
endpoint: process.env.S3_ENDPOINT || undefined,
publicAccess: process.env.S3_PUBLIC_ACCESS,
};
export const s3BucketParams = {
Bucket: process.env.S3_BUCKET!,
};
export const s3 = new S3(s3Config);
export const awsConfig = {
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_KEY!,
secretAccessKey: process.env.AWS_SECRET!,
},
};

View File

@ -9,4 +9,4 @@ try {
console.error(error); console.error(error);
} }
process.env.NUXT_PUBLIC_CLOUDFRONT = `https://${process.env.AWS_CLOUDFRONT_ID}.cloudfront.net`; process.env.NUXT_PUBLIC_CLOUDFRONT = process.env.S3_PUBLIC_ACCESS;

View File

@ -1,7 +1,6 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { S3 } from '@aws-sdk/client-s3';
import { loadImage, createCanvas } from 'canvas'; import { loadImage, createCanvas } from 'canvas';
import type { Canvas } from 'canvas'; import type { Canvas } from 'canvas';
import { Router } from 'express'; import { Router } from 'express';
@ -10,12 +9,12 @@ import sharp from 'sharp';
import SQL from 'sql-template-strings'; import SQL from 'sql-template-strings';
import { ulid } from 'ulid'; import { ulid } from 'ulid';
import { handleErrorAsync } from '../../src/helpers.ts';
import { auditLog } from '../audit.ts'; import { auditLog } from '../audit.ts';
import { awsConfig, awsParams } from '../aws.ts'; import { s3, s3BucketParams } from '../cloudServices.ts';
import { rootDir } from '../paths.ts'; import { rootDir } from '../paths.ts';
import { getLocale } from '~/server/data.ts'; import { getLocale } from '~/server/data.ts';
import { handleErrorAsync } from '~/src/helpers.ts';
const sizes = { const sizes = {
big: [1200, false], big: [1200, false],
@ -67,8 +66,6 @@ router.post('/images/upload', handleErrorAsync(async (req, res) => {
return res.status(400); return res.status(400);
} }
const s3 = new S3(awsConfig);
const requestedSizes = req.query.sizes === undefined || req.query.sizes === 'all' const requestedSizes = req.query.sizes === undefined || req.query.sizes === 'all'
? null ? null
: (req.query.sizes as string).split(','); : (req.query.sizes as string).split(',');
@ -103,7 +100,7 @@ router.post('/images/upload', handleErrorAsync(async (req, res) => {
Body: canvas.toBuffer('image/png'), Body: canvas.toBuffer('image/png'),
ContentType: 'image/png', ContentType: 'image/png',
ACL: 'public-read', ACL: 'public-read',
...awsParams, ...s3BucketParams,
}); });
} }

View File

@ -2,7 +2,7 @@
import fs from 'fs'; import fs from 'fs';
import zlib from 'node:zlib'; import zlib from 'node:zlib';
import { S3, NoSuchKey } from '@aws-sdk/client-s3'; import { NoSuchKey } from '@aws-sdk/client-s3';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { Router } from 'express'; import { Router } from 'express';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
@ -37,7 +37,7 @@ import { socialProviders } from '../../src/socialProviders.ts';
import { colours, styles } from '../../src/styling.ts'; import { colours, styles } from '../../src/styling.ts';
import { auditLog } from '../audit.ts'; import { auditLog } from '../audit.ts';
import avatar from '../avatar.ts'; import avatar from '../avatar.ts';
import { awsConfig, awsParams } from '../aws.ts'; import { s3, s3BucketParams } from '../cloudServices.ts';
import crypto from '../crypto.ts'; import crypto from '../crypto.ts';
import type { Database } from '../db.ts'; import type { Database } from '../db.ts';
import { rootDir } from '../paths.ts'; import { rootDir } from '../paths.ts';
@ -1177,13 +1177,12 @@ router.get('/profile/export', handleErrorAsync(async (req: Request, res: Respons
profiles[profile.locale] = exportProfile; profiles[profile.locale] = exportProfile;
} }
const s3 = new S3(awsConfig);
const images: ProfileExportData['images'] = {}; const images: ProfileExportData['images'] = {};
for (const id of customFlagIds) { for (const id of customFlagIds) {
try { try {
const data = await s3.getObject({ const data = await s3.getObject({
Key: `images/${id}-flag.png`, Key: `images/${id}-flag.png`,
...awsParams, ...s3BucketParams,
}); });
images[id] = { images[id] = {
flag: await data.Body!.transformToString('base64'), flag: await data.Body!.transformToString('base64'),
@ -1243,13 +1242,12 @@ router.post('/profile/import', handleErrorAsync(async (req, res) => {
const { profiles, images } = JSON.parse(Buffer.from(payload, 'base64').toString('utf-8')) as ProfileExportData; const { profiles, images } = JSON.parse(Buffer.from(payload, 'base64').toString('utf-8')) as ProfileExportData;
const s3 = new S3(awsConfig);
for (const [id, sizes] of Object.entries(images)) { for (const [id, sizes] of Object.entries(images)) {
for (const [size, content] of Object.entries(sizes)) { for (const [size, content] of Object.entries(sizes)) {
try { try {
await s3.headObject({ await s3.headObject({
Key: `images/${id}-${size}.png`, Key: `images/${id}-${size}.png`,
...awsParams, ...s3BucketParams,
}); });
continue; continue;
} catch (error) { } catch (error) {
@ -1263,7 +1261,7 @@ router.post('/profile/import', handleErrorAsync(async (req, res) => {
Body: Buffer.from(content, 'base64'), Body: Buffer.from(content, 'base64'),
ContentType: 'image/png', ContentType: 'image/png',
ACL: 'public-read', ACL: 'public-read',
...awsParams, ...s3BucketParams,
}); });
} }
} }

View File

@ -1,20 +1,21 @@
import { Polly } from '@aws-sdk/client-polly'; import { Polly } from '@aws-sdk/client-polly';
import { S3, NoSuchKey } from '@aws-sdk/client-s3'; import { NoSuchKey } from '@aws-sdk/client-s3';
import type { S3 } from '@aws-sdk/client-s3';
import type { NodeJsClient } from '@smithy/types'; import type { NodeJsClient } from '@smithy/types';
import { Router } from 'express'; import { Router } from 'express';
import { getH3Event } from 'h3-express'; import { getH3Event } from 'h3-express';
import { Base64 } from 'js-base64'; import { Base64 } from 'js-base64';
import sha1 from 'sha1'; import sha1 from 'sha1';
import { s3, awsConfig, s3BucketParams } from '../cloudServices.ts';
import type { PronunciationVoiceConfig, import type { PronunciationVoiceConfig,
AwsPollyPronunciationVoiceConfig, AwsPollyPronunciationVoiceConfig,
NarakeetPronunciationVoiceConfig } from '../../locale/config.ts'; NarakeetPronunciationVoiceConfig } from '~/locale/config.ts';
import { getLocale, loadConfig } from '~/server/data.ts';
import { convertPronunciationStringToSsml, import { convertPronunciationStringToSsml,
convertPronunciationStringToNarakeetFormat, convertPronunciationStringToNarakeetFormat,
handleErrorAsync } from '../../src/helpers.ts'; handleErrorAsync } from '~/src/helpers.ts';
import { awsConfig, awsParams } from '../aws.ts';
import { getLocale, loadConfig } from '~/server/data.ts';
const router = Router(); const router = Router();
@ -91,14 +92,12 @@ router.get('/pronounce/:voice/:pronunciation', handleErrorAsync(async (req, res)
return res.status(404).json({ error: 'Not found' }); return res.status(404).json({ error: 'Not found' });
} }
const s3 = new S3(awsConfig) as NodeJsClient<S3>;
const provider = providers[(voice.provider || 'aws_polly') as ProviderKey]; const provider = providers[(voice.provider || 'aws_polly') as ProviderKey];
const tokenised = provider.tokenised(text); const tokenised = provider.tokenised(text);
const key = `pronunciation/${config.locale}-${req.params.voice}/${sha1(tokenised)}.mp3`; const key = `pronunciation/${config.locale}-${req.params.voice}/${sha1(tokenised)}.mp3`;
try { try {
const s3Response = await s3.getObject({ Key: key, ...awsParams }); const s3Response = await (s3 as NodeJsClient<S3>).getObject({ Key: key, ...s3BucketParams });
res.set('content-type', s3Response.ContentType); res.set('content-type', s3Response.ContentType);
return s3Response.Body!.pipe(res); return s3Response.Body!.pipe(res);
} catch (error) { } catch (error) {
@ -112,7 +111,7 @@ router.get('/pronounce/:voice/:pronunciation', handleErrorAsync(async (req, res)
Key: key, Key: key,
Body: buffer, Body: buffer,
ContentType: contentType, ContentType: contentType,
...awsParams, ...s3BucketParams,
}); });
res.set('content-type', contentType); res.set('content-type', contentType);

View File

@ -1,11 +1,8 @@
import { S3 } from '@aws-sdk/client-s3';
import { loadImage, createCanvas } from 'canvas'; import { loadImage, createCanvas } from 'canvas';
import { awsConfig, awsParams } from '~/server/aws.ts'; import { s3, s3BucketParams } from '~/server/cloudServices.ts';
import { newDate, sha256 } from '~/src/helpers.ts'; import { newDate, sha256 } from '~/src/helpers.ts';
const s3 = new S3(awsConfig);
const convertToPng = async (original: Buffer): Promise<Buffer> => { const convertToPng = async (original: Buffer): Promise<Buffer> => {
const image = await loadImage(original); const image = await loadImage(original);
const canvas = createCanvas(image.width, image.height); const canvas = createCanvas(image.width, image.height);
@ -24,7 +21,7 @@ export default async (prefix: string, url: string, ttlDays: number | null = null
const key = `${prefix}/${await sha256(url)}.${isSvg ? 'svg' : 'png'}`; const key = `${prefix}/${await sha256(url)}.${isSvg ? 'svg' : 'png'}`;
try { try {
const metadata = await s3.headObject({ Key: key, ...awsParams }); const metadata = await s3.headObject({ Key: key, ...s3BucketParams });
if ( if (
ttlDays !== null && ttlDays !== null &&
@ -49,7 +46,7 @@ export default async (prefix: string, url: string, ttlDays: number | null = null
? 'image/svg+xml' ? 'image/svg+xml'
: 'image/png', : 'image/png',
ACL: 'public-read', ACL: 'public-read',
...awsParams, ...s3BucketParams,
}); });
return `${process.env.NUXT_PUBLIC_CLOUDFRONT}/${key}`; return `${process.env.NUXT_PUBLIC_CLOUDFRONT}/${key}`;