Merge branch 'aws-v3-migration' into 'main'

aws v3 migration

See merge request PronounsPage/PronounsPage!434
This commit is contained in:
Valentyne Stigloher 2024-03-28 13:23:03 +00:00
commit 3321f31917
17 changed files with 1312 additions and 219 deletions

View File

@ -1,3 +1,5 @@
import type { Engine, LanguageCode, VoiceId } from '@aws-sdk/client-polly';
type Toggable<T> = ({ enabled: true } & T) | { enabled: false } & Partial<T>;
export interface Config {
@ -283,15 +285,15 @@ interface PronunciationVoiceConfig {
/**
* language code
*/
language: string;
language: LanguageCode;
/**
* voice name
*/
voice: string;
voice: VoiceId;
/**
* voice engine
*/
engine: 'standard' | 'neural';
engine: Engine;
}
interface SourcesConfig {

View File

@ -44,8 +44,8 @@ pronouns:
pronunciation:
enabled: true
voices:
GB:
language: 'ko-KO'
KO:
language: 'ko-KR'
voice: 'Seoyeon'
engine: 'neural'

View File

@ -690,7 +690,7 @@ const nuxtConfig: NuxtConfig = {
listen(_server, { port }) {
if (version) {
process.stderr.write(`[${new Date().toISOString()}] ` +
`Listening on port ${port} with version ${version}`);
`Listening on port ${port} with version ${version}\n`);
}
},
},

View File

@ -13,6 +13,8 @@
"test": "node --experimental-vm-modules $(yarn bin jest)"
},
"dependencies": {
"@aws-sdk/client-polly": "^3.525.0",
"@aws-sdk/client-s3": "^3.525.0",
"@nuxtjs/axios": "^5.13.6",
"@nuxtjs/pwa": "3.3.5",
"@nuxtjs/redirect-module": "^0.3.1",
@ -23,7 +25,6 @@
"avris-futurus": "^1.0.2",
"avris-generator": "^0.8.2",
"avris-sorter": "^0.0.3",
"aws-sdk": "^2.1425.0",
"canvas": "^2.11.2",
"cookie-parser": "^1.4.5",
"cookie-universal-nuxt": "^2.1.4",
@ -93,11 +94,13 @@
"@types/js-md5": "^0.7.2",
"@types/jsonwebtoken": "^8.5.9",
"@types/luxon": "^1.27.1",
"@types/multer": "1.4.5",
"@types/node": "^20.11.5",
"@types/node-fetch": "^2.6.11",
"@types/nodemailer": "^6.4.14",
"@types/papaparse": "^5.3.14",
"@types/rtlcss": "^3.1.2",
"@types/sharp": "^0.31.1",
"@types/speakeasy": "^2.0.10",
"@types/webpack": "^4.41.38",
"@typescript-eslint/eslint-plugin": "^6.19.0",

View File

@ -1,12 +1,12 @@
import fetch from 'node-fetch';
import S3 from 'aws-sdk/clients/s3.js';
import awsConfig from './aws.js';
import { S3 } from '@aws-sdk/client-s3';
import { awsConfig, awsParams } from './aws.ts';
import md5 from 'js-md5';
import { loadImage, createCanvas } from 'canvas';
const s3 = new S3(awsConfig);
export default async (provider, url) => {
export default async (provider: string, url: string): Promise<string | null> => {
if (!url) {
return null;
}
@ -14,7 +14,7 @@ export default async (provider, url) => {
const key = `images-copy/${provider}/${md5(url)}.png`;
try {
await s3.headObject({ Key: key }).promise();
await s3.headObject({ Key: key, ...awsParams });
return `${process.env.CLOUDFRONT}/${key}`;
} catch {
@ -28,7 +28,8 @@ export default async (provider, url) => {
Body: canvas.toBuffer('image/png'),
ContentType: 'image/png',
ACL: 'public-read',
}).promise();
...awsParams,
});
return `${process.env.CLOUDFRONT}/${key}`;
} catch (e) {

View File

@ -1,10 +0,0 @@
export default {
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_KEY,
secretAccessKey: process.env.AWS_SECRET,
},
params: {
Bucket: process.env.AWS_S3_BUCKET,
},
};

11
server/aws.ts Normal file
View File

@ -0,0 +1,11 @@
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,32 +1,34 @@
import './setup.ts';
import type { Screenshot } from 'pageres';
import Pageres from 'pageres';
import * as Sentry from '@sentry/node';
import isHighLoadTime from './overload.js';
import type { Database } from './db.ts';
import dbConnection from './db.ts';
import allLocales from '../locale/locales.ts';
import { ulid } from 'ulid';
import awsConfig from './aws.js';
import S3 from 'aws-sdk/clients/s3.js';
import { awsConfig, awsParams } from './aws.ts';
import { S3 } from '@aws-sdk/client-s3';
const s3 = new S3(awsConfig);
const urlBases = {};
const urlBases: Record<string, string> = {};
for (const { code, url } of allLocales) {
urlBases[code] = `${url}/card/@`; // 'http://localhost:3000/card/@'
}
const domainLocaleMap = {};
const domainLocaleMap: Record<string, string> = {};
for (const { code, url } of allLocales) {
domainLocaleMap[url.replace(/^https?:\/\//, '')] = code;
}
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
const sleep = (ms: number): Promise<void> => new Promise((res) => setTimeout(res, ms));
const modes = ['light', 'dark'];
const modes = ['light', 'dark'] as const;
const shoot = async (db, mode) => {
const profiles = (await db.all(`
const shoot = async (db: Database, mode: 'light' | 'dark'): Promise<void> => {
const profiles = (await db.all<{ id: string, locale: string, username: string }>(`
SELECT profiles.id, profiles.locale, users.username
FROM profiles
LEFT JOIN users on profiles.userId = users.id
@ -40,7 +42,7 @@ const shoot = async (db, mode) => {
return;
}
const results = {};
const results: Record<string, Screenshot> = {};
try {
const pr = new Pageres({
@ -55,7 +57,7 @@ const shoot = async (db, mode) => {
}
for (const buffer of await pr.run()) {
const [, domain, username] = buffer.filename.match(/(.*)!card!@(.*)-1024x300\.png/);
const [, domain, username] = buffer.filename.match(/(.*)!card!@(.*)-1024x300\.png/)!;
const locale = domainLocaleMap[domain];
results[`${locale}/${username.replace(/[^A-Za-z0-9.-]/g, '_')}`] = buffer;
}
@ -85,16 +87,17 @@ const shoot = async (db, mode) => {
Body: buffer,
ContentType: 'image/png',
ACL: 'public-read',
}).promise();
...awsParams,
});
await db.get(`
UPDATE profiles
SET ${mode === 'dark' ? 'cardDark' : 'card'}='https://${awsConfig.params.Bucket}.s3.${awsConfig.region}.amazonaws.com/${key}'
SET ${mode === 'dark' ? 'cardDark' : 'card'}='https://${awsParams.Bucket}.s3.${awsConfig.region}.amazonaws.com/${key}'
WHERE id='${id}'`);
}
};
(async () => {
(async (): Promise<void> => {
const db = await dbConnection();
while (true) {
for (const mode of modes) {

View File

@ -1,18 +1,19 @@
import './setup.ts';
import dbConnection from './db.ts';
import awsConfig from './aws.js';
import S3 from 'aws-sdk/clients/s3.js';
import { awsConfig, awsParams } from './aws.ts';
import { S3 } from '@aws-sdk/client-s3';
import type { ListObjectsV2Output, ObjectIdentifier, _Object } from '@aws-sdk/client-s3';
const execute = process.env.EXECUTE === '1';
console.log(execute ? 'WILL REMOVE FILES!' : 'Dry run');
async function cleanup() {
async function cleanup(): Promise<void> {
console.log('--- Fetching ids expected to stay ---');
const db = await dbConnection();
const avatars = {};
const avatars: Record<string, true> = {};
for (const row of await db.all(`
SELECT avatarSource
FROM users
@ -20,7 +21,7 @@ async function cleanup() {
avatars[row.avatarSource.match('https://[^/]+/images/(.*)-(?:thumb|avatar).png')[1]] = true;
}
const flags = {};
const flags: Record<string, true> = {};
for (const row of await db.all(`
SELECT customFlags
FROM profiles
@ -31,7 +32,7 @@ async function cleanup() {
}
}
const sources = {};
const sources: Record<string, true> = {};
for (const row of await db.all(`
SELECT images
FROM sources
@ -42,7 +43,7 @@ async function cleanup() {
}
}
const terms = {};
const terms: Record<string, true> = {};
for (const row of await db.all(`
SELECT images
FROM terms
@ -53,7 +54,7 @@ async function cleanup() {
}
}
const cards = {};
const cards: Record<string, true> = {};
for (const row of await db.all(`
SELECT card
FROM profiles
@ -90,33 +91,37 @@ async function cleanup() {
let removedSize = 0;
const chunkSize = 1000;
let marker = undefined;
let continuationToken: string | undefined = undefined;
while (true) {
console.log('Making a request');
const objects = await s3.listObjects({
const objects: ListObjectsV2Output = await s3.listObjectsV2({
Prefix: 'images/',
MaxKeys: chunkSize,
Marker: marker,
}).promise();
ContinuationToken: continuationToken,
...awsParams,
});
if (!objects.Contents) {
break;
}
continuationToken = objects.NextContinuationToken;
const toRemove = [];
const remove = async (object, reason) => {
const toRemove: ObjectIdentifier[] = [];
const remove = async (object: _Object, reason: string): Promise<void> => {
console.log(`REMOVING: ${object.Key} (${reason})`);
toRemove.push({ Key: object.Key });
toRemove.push({ Key: object.Key! });
removed += 1;
removedSize += object.Size;
removedSize += object.Size!;
};
for (const object of objects.Contents) {
overall++;
marker = object.Key;
if (object.LastModified > new Date() - 60 * 60 * 1000) {
if (object.LastModified!.getTime() > new Date().getTime() - 60 * 60 * 1000) {
fresh++;
continue;
}
const [, id, size] = object.Key.match('images/(.*)-(.*).png');
const [, id, size] = object.Key!.match('images/(.*)-(.*).png')!;
if (avatars[id]) {
if (size !== 'thumb' && size !== 'avatar') {
@ -145,7 +150,8 @@ async function cleanup() {
Delete: {
Objects: toRemove,
},
}).promise();
...awsParams,
});
}
if (objects.Contents.length < chunkSize) {
@ -154,35 +160,39 @@ async function cleanup() {
}
console.log('--- Cards ---');
marker = undefined;
continuationToken = undefined;
while (true) {
console.log('Making a request');
const objects = await s3.listObjects({
const objects: ListObjectsV2Output = await s3.listObjectsV2({
Prefix: 'card/',
MaxKeys: chunkSize,
Marker: marker,
}).promise();
ContinuationToken: continuationToken,
...awsParams,
});
if (!objects.Contents) {
break;
}
continuationToken = objects.NextContinuationToken;
const toRemove = [];
for (const object of objects.Contents) {
overall++;
marker = object.Key;
if (object.LastModified > new Date() - 60 * 60 * 1000) {
if (object.LastModified!.getTime() > new Date().getTime() - 60 * 60 * 1000) {
fresh++;
continue;
}
const id = object.Key.endsWith('-dark.png')
? object.Key.match('card/[^/]+/.+-([^-]+)-dark\.png')[1]
: object.Key.match('card/[^/]+/.+-([^-]+)\.png')[1];
const id = object.Key!.endsWith('-dark.png')
? object.Key!.match('card/[^/]+/.+-([^-]+)-dark\.png')![1]
: object.Key!.match('card/[^/]+/.+-([^-]+)\.png')![1];
if (!cards[id]) {
console.log(`REMOVING: ${object.Key}`);
toRemove.push({ Key: object.Key });
toRemove.push({ Key: object.Key! });
removed += 1;
removedSize += object.Size;
removedSize += object.Size!;
}
}
@ -192,7 +202,8 @@ async function cleanup() {
Delete: {
Objects: toRemove,
},
}).promise();
...awsParams,
});
}
if (objects.Contents.length < chunkSize) {

View File

@ -33,10 +33,10 @@ import sourcesRoute from './routes/sources.js';
import nounsRoute from './routes/nouns.js';
import inclusiveRoute from './routes/inclusive.js';
import termsRoute from './routes/terms.js';
import pronounceRoute from './routes/pronounce.js';
import pronounceRoute from './routes/pronounce.ts';
import censusRoute from './routes/census.js';
import namesRoute from './routes/names.js';
import imagesRoute from './routes/images.js';
import imagesRoute from './routes/images.ts';
import blogRoute from './routes/blog.js';
import calendarRoute from './routes/calendar.js';
import translationsRoute from './routes/translations.ts';

View File

@ -2,6 +2,7 @@ import { Router } from 'express';
import { ulid } from 'ulid';
import multer from 'multer';
import { loadImage, createCanvas } from 'canvas';
import type { Canvas } from 'canvas';
import { handleErrorAsync } from '../../src/helpers.ts';
import sharp from 'sharp';
import fs from 'fs';
@ -9,19 +10,25 @@ import SQL from 'sql-template-strings';
import path from 'path';
import auditLog from '../audit.ts';
import awsConfig from '../aws.js';
import S3 from 'aws-sdk/clients/s3.js';
import { awsConfig, awsParams } from '../aws.ts';
import { S3 } from '@aws-sdk/client-s3';
const sizes = {
big: [1200, false],
flag: [256, false],
thumb: [192, true],
avatar: [144, true],
};
} as const;
const resizeImage = (image, width, height, sx = null, sy = null) => {
const resizeImage = (
image: Canvas,
width: number,
height: number,
sx: number | null = null,
sy: number | null = null,
): Canvas => {
const canvas = createCanvas(width, height);
if (sx === null) {
if (sx === null || sy === null) {
canvas.getContext('2d').drawImage(image, 0, 0, width, height);
} else {
canvas.getContext('2d').drawImage(image, sx, sy, width, height, 0, 0, width, height);
@ -30,13 +37,13 @@ const resizeImage = (image, width, height, sx = null, sy = null) => {
return canvas;
};
const cropToSquare = (image) => {
const cropToSquare = (image: Canvas): Canvas => {
return image.width > image.height
? resizeImage(image, image.height, image.height, (image.width - image.height) / 2, 0)
: resizeImage(image, image.width, image.width, 0, (image.height - image.width) / 2);
};
const scaleDownTo = (image, size) => {
const scaleDownTo = (image: Canvas, size: number): Canvas => {
if (image.width > image.height) {
return image.width > size
? resizeImage(image, size, image.height * size / image.width)
@ -50,15 +57,15 @@ const scaleDownTo = (image, size) => {
const router = Router();
router.post('/images/upload', multer({ limits: { fileSize: 10 * 1024 * 1024 } }).any('images[]', 12), handleErrorAsync(async (req, res) => {
router.post('/images/upload', multer({ limits: { fileSize: 10 * 1024 * 1024 } }).any(), handleErrorAsync(async (req, res) => {
const s3 = new S3(awsConfig);
const requestedSizes = req.query.sizes === undefined || req.query.sizes === 'all'
? null
: req.query.sizes.split(',');
: (req.query.sizes as string).split(',');
const ids = [];
for (const file of req.files) {
for (const file of req.files as Express.Multer.File[]) {
const id = ulid();
let image;
try {
@ -68,15 +75,10 @@ router.post('/images/upload', multer({ limits: { fileSize: 10 * 1024 * 1024 } })
return res.status(400).json({ error: 'error.invalidImage' });
}
for (const s in sizes) {
if (!sizes.hasOwnProperty(s)) {
for (const [name, [size, square]] of Object.entries(sizes)) {
if (requestedSizes !== null && !requestedSizes.includes(name)) {
continue;
}
if (requestedSizes !== null && !requestedSizes.includes(s)) {
continue;
}
const [size, square] = sizes[s];
let canvas = createCanvas(image.width, image.height);
canvas.getContext('2d').drawImage(image, 0, 0);
@ -88,11 +90,12 @@ router.post('/images/upload', multer({ limits: { fileSize: 10 * 1024 * 1024 } })
canvas = scaleDownTo(canvas, size);
await s3.putObject({
Key: `images/${id}-${s}.png`,
Key: `images/${id}-${name}.png`,
Body: canvas.toBuffer('image/png'),
ContentType: 'image/png',
ACL: 'public-read',
}).promise();
...awsParams,
});
}
ids.push(id);
@ -122,9 +125,12 @@ router.get('/downloads', handleErrorAsync(async (req, res) => {
return res.status(401).json({ error: 'Unauthorised' });
}
const stats = {};
const stats: Record<string, number> = {};
for (const { filename, c } of await req.db.all(SQL`SELECT filename, count(*) as c FROM downloads WHERE locale = ${global.config.locale} GROUP BY filename;`)) {
const downloads = await req.db.all<{ filename: string, c: number }>(SQL`
SELECT filename, count(*) as c FROM downloads WHERE locale = ${global.config.locale} GROUP BY filename;
`);
for (const { filename, c } of downloads) {
stats[filename] = c;
}

View File

@ -15,8 +15,8 @@ import { normaliseUrl } from '../../src/links.js';
import allLocales from '../../locale/locales.ts';
import auditLog from '../audit.ts';
import crypto from '../../src/crypto.js';
import awsConfig from '../aws.js';
import S3 from 'aws-sdk/clients/s3.js';
import { awsConfig, awsParams } from '../aws.ts';
import { S3, NoSuchKey } from '@aws-sdk/client-s3';
import zlib from 'zlib';
import multer from 'multer';
@ -981,7 +981,8 @@ router.get('/profile/export', handleErrorAsync(async (req, res) => {
try {
const data = await s3.getObject({
Key: `images/${id}-flag.png`,
}).promise();
...awsParams,
});
images[id] = {
flag: data.Body.toString('base64'),
};
@ -1037,16 +1038,22 @@ router.post('/profile/import', multer({ limits: { fileSize: 10 * 1024 * 1024 } }
try {
await s3.headObject({
Key: `images/${id}-${size}.png`,
}).promise();
...awsParams,
});
continue;
} catch (error) {}
} catch (error) {
if (!(error instanceof NoSuchKey)) {
throw error;
}
}
await s3.putObject({
Key: `images/${id}-${size}.png`,
Body: Buffer.from(content, 'base64'),
ContentType: 'image/png',
ACL: 'public-read',
}).promise();
...awsParams,
});
}
}

View File

@ -3,9 +3,10 @@ import sha1 from 'sha1';
import { convertPronunciationStringToSsml, handleErrorAsync } from '../../src/helpers.ts';
import { Base64 } from 'js-base64';
import awsConfig from '../aws.js';
import Polly from 'aws-sdk/clients/polly.js';
import S3 from 'aws-sdk/clients/s3.js';
import { awsConfig, awsParams } from '../aws.ts';
import { Polly } from '@aws-sdk/client-polly';
import { S3, NoSuchKey } from '@aws-sdk/client-s3';
import type { NodeJsClient } from '@smithy/types';
const router = Router();
@ -16,21 +17,25 @@ router.get('/pronounce/:voice/:pronunciation', handleErrorAsync(async (req, res)
return res.status(404).json({ error: 'Not found' });
}
const voice = global.config.pronunciation.voices[req.params.voice];
const voice = global.config.pronunciation?.voices?.[req.params.voice];
if (!voice) {
return res.status(404).json({ error: 'Not found' });
}
const s3 = new S3(awsConfig);
const polly = new Polly(awsConfig);
const s3 = new S3(awsConfig) as NodeJsClient<S3>;
const polly = new Polly(awsConfig) as NodeJsClient<Polly>;
const ssml = convertPronunciationStringToSsml(text);
const key = `pronunciation/${global.config.locale}-${req.params.voice}/${sha1(ssml)}.mp3`;
try {
const s3getResponse = await s3.getObject({ Key: key }).promise();
return res.set('content-type', s3getResponse.ContentType).send(s3getResponse.Body);
} catch {
const s3Response = await s3.getObject({ Key: key, ...awsParams });
res.set('content-type', s3Response.ContentType);
return s3Response.Body!.pipe(res);
} catch (error) {
if (!(error instanceof NoSuchKey)) {
throw error;
}
const pollyResponse = await polly.synthesizeSpeech({
TextType: 'ssml',
Text: ssml,
@ -38,15 +43,18 @@ router.get('/pronounce/:voice/:pronunciation', handleErrorAsync(async (req, res)
LanguageCode: voice.language,
VoiceId: voice.voice,
Engine: voice.engine,
}).promise();
});
const buffer = await pollyResponse.AudioStream!.transformToByteArray();
await s3.putObject({
Key: key,
Body: pollyResponse.AudioStream,
Body: buffer,
ContentType: pollyResponse.ContentType,
}).promise();
...awsParams,
});
return res.set('content-type', pollyResponse.ContentType).send(pollyResponse.AudioStream);
res.set('content-type', pollyResponse.ContentType);
res.write(buffer);
return res.end();
}
}));

View File

@ -14,7 +14,7 @@ import assert from 'assert';
import { addMfaInfo } from './mfa.js';
import buildLocaleList from '../../src/buildLocaleList.ts';
import { lookupBanArchive } from '../ban.js';
import copyAvatar from '../avatarCopy.js';
import copyAvatar from '../avatarCopy.ts';
import { usernameRegex, usernameUnsafeRegex } from '../../src/username.ts';
const config = loadSuml('config');
import auditLog from '../audit.ts';

4
server/sha1.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module 'sha1' {
const sha1: (message: string) => string;
export = sha1;
}

View File

@ -258,7 +258,7 @@ export const isGranted = (user: Pick<User, 'roles'>, locale: string | null, area
return false;
};
type ErrorAsyncFunction = (req: Request, res: Response, next?: NextFunction) => Promise<Response>;
type ErrorAsyncFunction = (req: Request, res: Response, next?: NextFunction) => Promise<unknown>;
export const handleErrorAsync = (func: ErrorAsyncFunction) => {
return (req: Request, res: Response, next: NextFunction): void => {
func(req, res, next).catch((error: unknown) => next(error));

1261
yarn.lock

File diff suppressed because it is too large Load Diff