Remplace les 3 workers par fichier par un pool global de morceaux en vol, réparti équitablement entre les fichiers fragmentés actifs : 1 fichier utilise tout le pool, N fichiers en reçoivent une part chacun (ceil(taille / N)), la capacité libérée par un fichier terminé est redistribuée. Un fichier en cours d'init compte dans la part pour ne pas être affamé ; les morceaux déjà en vol ne sont jamais interrompus. La taille du pool est réglable via `chunkPoolSize` (config.json, défaut 30, borné à 256) et transmise au navigateur par un nouveau `GET /config`. Corps des morceaux repassé en streaming (writeChunkAt sur un flux + garde `maxBytes`) : mémoire ~constante même à pool élevé, plus de bufferisation de 16 Mo par requête. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
206 lines
6.8 KiB
TypeScript
206 lines
6.8 KiB
TypeScript
import { mkdtemp, rm } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import path from 'node:path';
|
||
|
||
import Fastify, { type FastifyInstance } from 'fastify';
|
||
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||
|
||
import type { AppConfig, DropTargets } from '../src/config.js';
|
||
|
||
export const TEST_PUBLIC_URL = 'http://localhost:3000';
|
||
export const TEST_CLIENT_ID = 'mediadrop-test';
|
||
export const TEST_CLIENT_SECRET = 'test-client-secret';
|
||
export const SESSION_COOKIE = 'mediadrop_session';
|
||
const TEST_KID = 'test-key';
|
||
|
||
/** Clé de session déterministe (32 octets) pour @fastify/secure-session. */
|
||
export const TEST_SESSION_KEY_B64 = Buffer.alloc(32, 7).toString('base64');
|
||
|
||
/** Petit PNG 1x1 (signature valide) ; seul le type MIME annoncé compte côté serveur. */
|
||
export const TINY_PNG = Buffer.from(
|
||
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000' +
|
||
'000a49444154789c6360000000020001e221bc330000000049454e44ae426082',
|
||
'hex',
|
||
);
|
||
|
||
export async function makeTempDir(): Promise<string> {
|
||
return mkdtemp(path.join(tmpdir(), 'mediadrop-'));
|
||
}
|
||
|
||
export async function removeDir(dir: string): Promise<void> {
|
||
await rm(dir, { recursive: true, force: true });
|
||
}
|
||
|
||
/** Liste à une seule cible pointant vers `dir`. */
|
||
export function oneTarget(dir: string, allowDirectories = false): DropTargets {
|
||
return [{ name: 'main', label: 'Principal', dir, allowDirectories }];
|
||
}
|
||
|
||
export function makeConfig(overrides: Partial<AppConfig> = {}): AppConfig {
|
||
return {
|
||
port: 0,
|
||
host: '127.0.0.1',
|
||
publicUrl: TEST_PUBLIC_URL,
|
||
targets: oneTarget('./uploads'),
|
||
maxFileSizeBytes: 1024 * 1024,
|
||
allowedMimeTypes: ['image/png', 'image/jpeg'],
|
||
logLevel: 'silent',
|
||
chunkPoolSize: 30,
|
||
session: { key: Buffer.alloc(32, 7), maxAgeSeconds: 3600 },
|
||
oidc: {
|
||
issuer: 'https://idp.test',
|
||
clientId: TEST_CLIENT_ID,
|
||
clientSecret: TEST_CLIENT_SECRET,
|
||
scopes: ['openid', 'profile', 'email'],
|
||
},
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
export interface NextIdToken {
|
||
nonce?: string;
|
||
sub?: string;
|
||
name?: string;
|
||
email?: string;
|
||
}
|
||
|
||
/** Fournisseur OIDC factice : `.well-known`, JWKS et `POST /token` signant un id_token RS256. */
|
||
export interface StubIdp {
|
||
issuer: string;
|
||
clientId: string;
|
||
clientSecret: string;
|
||
/** Claims du prochain id_token émis par `POST /token`. */
|
||
setNextIdToken: (claims: NextIdToken) => void;
|
||
close: () => Promise<void>;
|
||
}
|
||
|
||
export async function startStubIdp(): Promise<StubIdp> {
|
||
const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true });
|
||
const publicJwk = { ...(await exportJWK(publicKey)), kid: TEST_KID, alg: 'RS256', use: 'sig' };
|
||
const jwks = { keys: [publicJwk] };
|
||
|
||
let next: Required<NextIdToken> = {
|
||
nonce: '',
|
||
sub: 'user-123',
|
||
name: 'Test User',
|
||
email: 'user@example.test',
|
||
};
|
||
|
||
const server = Fastify({ logger: false });
|
||
|
||
// openid-client POSTe le token request en x-www-form-urlencoded ; on accepte sans le parser.
|
||
server.addContentTypeParser(
|
||
'application/x-www-form-urlencoded',
|
||
{ parseAs: 'string' },
|
||
(_request, body, done) => {
|
||
done(null, body);
|
||
},
|
||
);
|
||
|
||
server.get('/jwks', () => jwks);
|
||
|
||
server.get('/.well-known/openid-configuration', (request) => {
|
||
const base = `http://${request.headers.host ?? '127.0.0.1'}`;
|
||
return {
|
||
issuer: base,
|
||
authorization_endpoint: `${base}/authorize`,
|
||
token_endpoint: `${base}/token`,
|
||
jwks_uri: `${base}/jwks`,
|
||
end_session_endpoint: `${base}/logout`,
|
||
userinfo_endpoint: `${base}/userinfo`,
|
||
response_types_supported: ['code'],
|
||
grant_types_supported: ['authorization_code'],
|
||
subject_types_supported: ['public'],
|
||
id_token_signing_alg_values_supported: ['RS256'],
|
||
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
|
||
code_challenge_methods_supported: ['S256'],
|
||
scopes_supported: ['openid', 'profile', 'email'],
|
||
};
|
||
});
|
||
|
||
server.post('/token', async (request) => {
|
||
const base = `http://${request.headers.host ?? '127.0.0.1'}`;
|
||
const idToken = await new SignJWT({
|
||
...(next.nonce.length > 0 ? { nonce: next.nonce } : {}),
|
||
name: next.name,
|
||
preferred_username: next.name,
|
||
email: next.email,
|
||
auth_time: Math.floor(Date.now() / 1000),
|
||
})
|
||
.setProtectedHeader({ alg: 'RS256', kid: TEST_KID })
|
||
.setIssuer(base)
|
||
.setSubject(next.sub)
|
||
.setAudience(TEST_CLIENT_ID)
|
||
.setIssuedAt()
|
||
.setExpirationTime('5m')
|
||
.sign(privateKey);
|
||
return {
|
||
access_token: 'stub-access-token',
|
||
token_type: 'Bearer',
|
||
expires_in: 300,
|
||
scope: 'openid profile email',
|
||
id_token: idToken,
|
||
};
|
||
});
|
||
|
||
await server.listen({ port: 0, host: '127.0.0.1' });
|
||
const address = server.server.address();
|
||
if (address === null || typeof address === 'string') {
|
||
await server.close();
|
||
throw new Error('stub IdP : adresse d’écoute inattendue');
|
||
}
|
||
|
||
return {
|
||
issuer: `http://127.0.0.1:${String(address.port)}`,
|
||
clientId: TEST_CLIENT_ID,
|
||
clientSecret: TEST_CLIENT_SECRET,
|
||
setNextIdToken(claims: NextIdToken): void {
|
||
next = {
|
||
nonce: '',
|
||
sub: 'user-123',
|
||
name: 'Test User',
|
||
email: 'user@example.test',
|
||
...claims,
|
||
};
|
||
},
|
||
close: () => server.close(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Déroule le flux de connexion via `app.inject` et renvoie la valeur du cookie de
|
||
* session authentifié, à repasser dans `cookies` pour les requêtes protégées.
|
||
*/
|
||
export async function login(
|
||
app: FastifyInstance,
|
||
idp: StubIdp,
|
||
user: { sub?: string; name?: string } = {},
|
||
): Promise<string> {
|
||
const start = await app.inject({ method: 'GET', url: '/auth/login' });
|
||
const location = start.headers.location;
|
||
if (typeof location !== 'string') {
|
||
throw new Error(`login: /auth/login n'a pas redirigé (${String(start.statusCode)})`);
|
||
}
|
||
const authUrl = new URL(location);
|
||
const state = authUrl.searchParams.get('state') ?? '';
|
||
const nonce = authUrl.searchParams.get('nonce') ?? '';
|
||
const txnCookie = start.cookies.find((cookie) => cookie.name === SESSION_COOKIE);
|
||
if (txnCookie === undefined) {
|
||
throw new Error('login: cookie de transaction absent');
|
||
}
|
||
|
||
idp.setNextIdToken({ nonce, sub: user.sub ?? 'user-123', name: user.name ?? 'Test User' });
|
||
|
||
const callback = await app.inject({
|
||
method: 'GET',
|
||
url: `/auth/callback?code=stub-code&state=${encodeURIComponent(state)}`,
|
||
cookies: { [SESSION_COOKIE]: txnCookie.value },
|
||
});
|
||
const sessionCookie = callback.cookies.find((cookie) => cookie.name === SESSION_COOKIE);
|
||
if (sessionCookie === undefined || callback.headers.location !== `${TEST_PUBLIC_URL}/`) {
|
||
throw new Error(
|
||
`login: callback en échec (${String(callback.statusCode)} -> ${String(callback.headers.location)})`,
|
||
);
|
||
}
|
||
return sessionCookie.value;
|
||
}
|