create-tigra 3.0.2 → 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/client/.env.example +19 -0
- package/template/client/Dockerfile +3 -3
- package/template/client/next.config.ts +7 -42
- package/template/client/package-lock.json +1896 -146
- package/template/client/package.json +2 -0
- package/template/client/src/app/layout.tsx +10 -3
- package/template/client/src/app/providers.tsx +15 -2
- package/template/client/src/instrumentation-client.ts +30 -0
- package/template/client/src/instrumentation.ts +38 -0
- package/template/client/src/lib/env.ts +13 -2
- package/template/client/src/middleware.ts +105 -18
- package/template/server/.env.example +22 -1
- package/template/server/.env.example.production +16 -1
- package/template/server/Dockerfile +7 -5
- package/template/server/package-lock.json +2136 -86
- package/template/server/package.json +6 -0
- package/template/server/src/app.ts +30 -11
- package/template/server/src/config/env.ts +23 -2
- package/template/server/src/config/rate-limit.config.ts +6 -0
- package/template/server/src/libs/logger.ts +15 -0
- package/template/server/src/libs/observability/sentry.ts +42 -0
- package/template/server/src/libs/requestLogger.ts +8 -2
- package/template/server/src/libs/storage/file-storage.service.ts +144 -16
- package/template/server/src/modules/admin/__tests__/admin.integration.test.ts +128 -0
- package/template/server/src/modules/auth/__tests__/auth.integration.test.ts +138 -0
- package/template/server/src/modules/files/__tests__/files.integration.test.ts +157 -0
- package/template/server/src/modules/files/files.controller.ts +180 -0
- package/template/server/src/modules/files/files.routes.ts +46 -0
- package/template/server/src/server.ts +6 -0
- package/template/server/src/test/integration.setup.ts +170 -0
- package/template/server/vitest.config.ts +10 -1
- package/template/server/vitest.integration.config.ts +50 -0
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
|
|
10
10
|
"start": "node dist/server.js",
|
|
11
11
|
"test": "vitest run",
|
|
12
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
12
13
|
"test:watch": "vitest",
|
|
13
14
|
"test:ui": "vitest --ui",
|
|
14
15
|
"test:coverage": "vitest run --coverage",
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
"prisma:studio": "prisma studio",
|
|
21
22
|
"lint": "eslint src/",
|
|
22
23
|
"typecheck": "tsc --noEmit",
|
|
24
|
+
"audit": "npm audit --audit-level=high",
|
|
23
25
|
"redis:flush": "tsx scripts/flush-redis.ts",
|
|
24
26
|
"docker:up": "docker compose up -d",
|
|
25
27
|
"docker:tools": "docker compose --profile tools up -d",
|
|
@@ -39,6 +41,7 @@
|
|
|
39
41
|
"@fastify/rate-limit": "^10.3.0",
|
|
40
42
|
"@fastify/static": "^9.1.3",
|
|
41
43
|
"@prisma/client": "^6.19.3",
|
|
44
|
+
"@sentry/node": "^10.62.0",
|
|
42
45
|
"argon2": "^0.44.0",
|
|
43
46
|
"axios": "^1.17.0",
|
|
44
47
|
"dotenv": "^16.4.7",
|
|
@@ -61,12 +64,15 @@
|
|
|
61
64
|
},
|
|
62
65
|
"devDependencies": {
|
|
63
66
|
"@eslint/js": "^10.0.1",
|
|
67
|
+
"@testcontainers/mysql": "^12.0.3",
|
|
68
|
+
"@testcontainers/redis": "^12.0.3",
|
|
64
69
|
"@types/node": "^20.17.10",
|
|
65
70
|
"@types/uuid": "^10.0.0",
|
|
66
71
|
"@vitest/coverage-v8": "^4.0.18",
|
|
67
72
|
"@vitest/ui": "^4.0.18",
|
|
68
73
|
"eslint": "^10.0.1",
|
|
69
74
|
"prisma": "^6.19.3",
|
|
75
|
+
"testcontainers": "^12.0.3",
|
|
70
76
|
"tsc-alias": "^1.8.16",
|
|
71
77
|
"tsx": "^4.21.0",
|
|
72
78
|
"typescript": "^5.9.3",
|
|
@@ -6,10 +6,10 @@ import cookie from '@fastify/cookie';
|
|
|
6
6
|
import jwt from '@fastify/jwt';
|
|
7
7
|
import multipart from '@fastify/multipart';
|
|
8
8
|
import fastifyStatic from '@fastify/static';
|
|
9
|
-
import
|
|
10
|
-
import { fileURLToPath } from 'url';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
11
10
|
import { env } from '@config/env.js';
|
|
12
11
|
import { logger } from '@libs/logger.js';
|
|
12
|
+
import { Sentry } from '@libs/observability/sentry.js';
|
|
13
13
|
import { markRequestStart, logRequestLine } from '@libs/requestLogger.js';
|
|
14
14
|
import { initAuth } from '@libs/auth.js';
|
|
15
15
|
import { registerQueryCounter } from '@libs/query-counter.js';
|
|
@@ -18,6 +18,7 @@ import { successResponse, errorResponse } from '@shared/responses/successRespons
|
|
|
18
18
|
import { authRoutes } from '@modules/auth/auth.routes.js';
|
|
19
19
|
import { usersRoutes } from '@modules/users/users.routes.js';
|
|
20
20
|
import { adminRoutes } from '@modules/admin/admin.routes.js';
|
|
21
|
+
import { filesRoutes } from '@modules/files/files.routes.js';
|
|
21
22
|
import { fileStorageService } from '@libs/storage/file-storage.service.js';
|
|
22
23
|
import { registerJobs } from '@jobs/index.js';
|
|
23
24
|
import { RATE_LIMIT_ENABLED, getRateLimitRedisStore } from '@config/rate-limit.config.js';
|
|
@@ -38,6 +39,10 @@ import type {} from '@shared/types/index.js';
|
|
|
38
39
|
export async function buildApp(): Promise<FastifyInstance> {
|
|
39
40
|
const app = Fastify({
|
|
40
41
|
logger: false,
|
|
42
|
+
// Request-id correlation: honour an inbound X-Request-Id (set by a reverse
|
|
43
|
+
// proxy / upstream service) so logs and Sentry events share one id across
|
|
44
|
+
// hops; otherwise mint a UUID. Works even with logger:false.
|
|
45
|
+
genReqId: (req) => (req.headers['x-request-id'] as string | undefined) ?? randomUUID(),
|
|
41
46
|
// Trust proxy headers (X-Forwarded-For) for accurate client IP behind Nginx/load balancer
|
|
42
47
|
trustProxy: env.NODE_ENV === 'production',
|
|
43
48
|
// Graceful shutdown configuration
|
|
@@ -134,19 +139,22 @@ export async function buildApp(): Promise<FastifyInstance> {
|
|
|
134
139
|
},
|
|
135
140
|
});
|
|
136
141
|
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
const __dirname = path.dirname(__filename);
|
|
142
|
+
// Initialize file storage (create both upload tiers) BEFORE mounting static,
|
|
143
|
+
// so the public root exists when @fastify/static checks it.
|
|
144
|
+
await fileStorageService.initialize();
|
|
141
145
|
|
|
146
|
+
// Static file serving for uploads — PUBLIC tier ONLY.
|
|
147
|
+
// SECURITY: the root is scoped to UPLOAD_PUBLIC_DIR (uploads/public), NOT the
|
|
148
|
+
// whole uploads/ tree. The PRIVATE tier (uploads/private) lives OUTSIDE this
|
|
149
|
+
// root and is unreachable through /uploads/* — it is served only by the
|
|
150
|
+
// auth-gated, owner-scoped route GET /api/v1/files/:filename (filesRoutes).
|
|
151
|
+
// The public avatar URL shape is unchanged: serving UPLOAD_PUBLIC_DIR at
|
|
152
|
+
// prefix '/uploads/' keeps avatars at /uploads/users/{id}/avatar/x.webp.
|
|
142
153
|
await app.register(fastifyStatic, {
|
|
143
|
-
root:
|
|
154
|
+
root: fileStorageService.getPublicDir(),
|
|
144
155
|
prefix: '/uploads/',
|
|
145
156
|
});
|
|
146
157
|
|
|
147
|
-
// Initialize file storage (create directories)
|
|
148
|
-
await fileStorageService.initialize();
|
|
149
|
-
|
|
150
158
|
// --- Sync permanent IP blocks from DB to Redis ---
|
|
151
159
|
await syncBlockedIpsToRedis();
|
|
152
160
|
|
|
@@ -158,6 +166,14 @@ export async function buildApp(): Promise<FastifyInstance> {
|
|
|
158
166
|
// These paths must match the route registrations below.
|
|
159
167
|
const monitoringPaths = new Set(['/api/v1/health', '/api/v1/ready', '/api/v1/live']);
|
|
160
168
|
|
|
169
|
+
// --- Request-id echo: surface request.id on the response so a client/proxy
|
|
170
|
+
// can correlate a failed call with the server log + Sentry event. Runs first
|
|
171
|
+
// so the header is present even when a later hook short-circuits (IP block,
|
|
172
|
+
// origin check). request.id is the genReqId value (inbound X-Request-Id or UUID).
|
|
173
|
+
app.addHook('onRequest', async (request, reply) => {
|
|
174
|
+
reply.header('x-request-id', request.id);
|
|
175
|
+
});
|
|
176
|
+
|
|
161
177
|
// --- IP Block Check (runs before everything else) ---
|
|
162
178
|
app.addHook('onRequest', async (request: FastifyRequest) => {
|
|
163
179
|
if (monitoringPaths.has(request.url.split('?')[0])) {
|
|
@@ -241,8 +257,10 @@ export async function buildApp(): Promise<FastifyInstance> {
|
|
|
241
257
|
return reply.status(error.statusCode).send(errorResponse(errorInfo.code, errorInfo.message));
|
|
242
258
|
}
|
|
243
259
|
|
|
244
|
-
// Unexpected error —
|
|
260
|
+
// Unexpected error — capture in Sentry (safe no-op when SDK uninitialized),
|
|
261
|
+
// then log and return a generic 500. reqId ties the event to the log line.
|
|
245
262
|
const requestId = request.id || 'unknown';
|
|
263
|
+
Sentry.captureException(error, { extra: { reqId: requestId } });
|
|
246
264
|
logger.error(
|
|
247
265
|
{
|
|
248
266
|
err: error,
|
|
@@ -306,6 +324,7 @@ export async function buildApp(): Promise<FastifyInstance> {
|
|
|
306
324
|
await app.register(authRoutes, { prefix: '/api/v1' });
|
|
307
325
|
await app.register(usersRoutes, { prefix: '/api/v1' });
|
|
308
326
|
await app.register(adminRoutes, { prefix: '/api/v1' });
|
|
327
|
+
await app.register(filesRoutes, { prefix: '/api/v1' });
|
|
309
328
|
|
|
310
329
|
// --- Background Jobs ---
|
|
311
330
|
registerJobs(app);
|
|
@@ -67,6 +67,17 @@ const envSchema = z.object({
|
|
|
67
67
|
// --- File Upload ---
|
|
68
68
|
MAX_FILE_SIZE_MB: optionalEnv(z.coerce.number().min(1).max(100).default(10)),
|
|
69
69
|
|
|
70
|
+
// --- Two-tier upload storage (see src/libs/storage/file-storage.service.ts) ---
|
|
71
|
+
// PUBLIC tier: served as static files at /uploads/ — viewable by ANYONE,
|
|
72
|
+
// including unauthenticated users (e.g. social-style avatars). @fastify/static
|
|
73
|
+
// is scoped to ONLY this directory so it can never serve a private file.
|
|
74
|
+
// PRIVATE tier: lives OUTSIDE the static root and is served ONLY through the
|
|
75
|
+
// auth-gated streaming route GET /api/v1/files/:filename (owner-scoped).
|
|
76
|
+
// Both optional with sensible <cwd>/uploads/{public,private} defaults so a
|
|
77
|
+
// clean env still boots; the storage service resolves the defaults at runtime.
|
|
78
|
+
UPLOAD_PUBLIC_DIR: optionalEnv(z.string().optional()),
|
|
79
|
+
UPLOAD_PRIVATE_DIR: optionalEnv(z.string().optional()),
|
|
80
|
+
|
|
70
81
|
// --- JWT Authentication ---
|
|
71
82
|
JWT_SECRET: z
|
|
72
83
|
.string()
|
|
@@ -118,8 +129,14 @@ const envSchema = z.object({
|
|
|
118
129
|
RESEND_FROM_EMAIL: optionalEnv(z.string().email().default('onboarding@resend.dev')),
|
|
119
130
|
CLIENT_URL: optionalEnv(z.string().url().default('http://localhost:3000')),
|
|
120
131
|
|
|
121
|
-
// --- Error Tracking (Optional) ---
|
|
132
|
+
// --- Error Tracking (Optional, Sentry) ---
|
|
133
|
+
// All inert without SENTRY_DSN — Sentry init becomes a no-op (see
|
|
134
|
+
// src/libs/observability/sentry.ts). Never hardcode a DSN.
|
|
122
135
|
SENTRY_DSN: optionalEnv(z.string().url().optional()),
|
|
136
|
+
// Fraction of transactions sampled for performance tracing (0..1).
|
|
137
|
+
SENTRY_TRACES_SAMPLE_RATE: optionalEnv(z.coerce.number().min(0).max(1).default(0.1)),
|
|
138
|
+
// Environment tag attached to Sentry events; defaults to NODE_ENV when unset.
|
|
139
|
+
SENTRY_ENVIRONMENT: optionalEnv(z.string().optional()),
|
|
123
140
|
});
|
|
124
141
|
|
|
125
142
|
const parsed = envSchema.safeParse(process.env);
|
|
@@ -145,6 +162,10 @@ if (parsed.data.REQUIRE_USER_VERIFICATION && !parsed.data.RESEND_API_KEY) {
|
|
|
145
162
|
process.exit(1);
|
|
146
163
|
}
|
|
147
164
|
|
|
148
|
-
export const env =
|
|
165
|
+
export const env = {
|
|
166
|
+
...parsed.data,
|
|
167
|
+
// Default the Sentry environment tag to NODE_ENV when not explicitly set.
|
|
168
|
+
SENTRY_ENVIRONMENT: parsed.data.SENTRY_ENVIRONMENT ?? parsed.data.NODE_ENV,
|
|
169
|
+
};
|
|
149
170
|
|
|
150
171
|
export type Env = z.infer<typeof envSchema>;
|
|
@@ -116,6 +116,12 @@ export const RATE_LIMITS = {
|
|
|
116
116
|
timeWindow: '1 minute',
|
|
117
117
|
},
|
|
118
118
|
|
|
119
|
+
// Files — auth-gated private file streaming
|
|
120
|
+
FILES_GET: {
|
|
121
|
+
max: applyMultiplier(100),
|
|
122
|
+
timeWindow: '1 minute',
|
|
123
|
+
},
|
|
124
|
+
|
|
119
125
|
// Admin routes
|
|
120
126
|
ADMIN_DEFAULT: {
|
|
121
127
|
max: applyMultiplier(30),
|
|
@@ -3,6 +3,21 @@ import { env } from '@config/env.js';
|
|
|
3
3
|
|
|
4
4
|
export const logger = pino({
|
|
5
5
|
level: env.LOG_LEVEL,
|
|
6
|
+
// Never let secrets reach the logs. Covers auth/cookie headers (raw and under
|
|
7
|
+
// the `req` serializer) plus any password/token/secret field at any depth.
|
|
8
|
+
// `remove: true` drops the key entirely rather than emitting "[Redacted]".
|
|
9
|
+
redact: {
|
|
10
|
+
paths: [
|
|
11
|
+
'req.headers.authorization',
|
|
12
|
+
'req.headers.cookie',
|
|
13
|
+
'headers.authorization',
|
|
14
|
+
'headers.cookie',
|
|
15
|
+
'*.password',
|
|
16
|
+
'*.token',
|
|
17
|
+
'*.secret',
|
|
18
|
+
],
|
|
19
|
+
remove: true,
|
|
20
|
+
},
|
|
6
21
|
...(env.NODE_ENV !== 'production' && {
|
|
7
22
|
transport: {
|
|
8
23
|
target: 'pino-pretty',
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentry (error tracking) — env-gated, INERT by default.
|
|
3
|
+
*
|
|
4
|
+
* With no `SENTRY_DSN` set, `initSentry()` short-circuits and the SDK is never
|
|
5
|
+
* initialized. `Sentry.captureException(...)` elsewhere is a safe no-op when the
|
|
6
|
+
* SDK is uninitialized, so the rest of the app needs no DSN-presence guards.
|
|
7
|
+
*
|
|
8
|
+
* Never hardcode a DSN here or anywhere — it comes only from the environment.
|
|
9
|
+
*/
|
|
10
|
+
import * as Sentry from '@sentry/node';
|
|
11
|
+
import { env } from '@config/env.js';
|
|
12
|
+
import { logger } from '@libs/logger.js';
|
|
13
|
+
|
|
14
|
+
let initialized = false;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Initialize Sentry only when a DSN is configured.
|
|
18
|
+
*
|
|
19
|
+
* Call this as early as possible in the process lifecycle (top of server.ts),
|
|
20
|
+
* BEFORE the app is built, so error capture is active before any request is
|
|
21
|
+
* handled. A missing DSN is a clean no-op (no throw).
|
|
22
|
+
*/
|
|
23
|
+
export function initSentry(): void {
|
|
24
|
+
if (initialized) return;
|
|
25
|
+
|
|
26
|
+
if (!env.SENTRY_DSN) {
|
|
27
|
+
// No DSN → stay inert. One quiet debug line; nothing in prod logs at info.
|
|
28
|
+
logger.debug('[OBSERVABILITY] Sentry disabled (no DSN)');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
Sentry.init({
|
|
33
|
+
dsn: env.SENTRY_DSN,
|
|
34
|
+
tracesSampleRate: env.SENTRY_TRACES_SAMPLE_RATE,
|
|
35
|
+
environment: env.SENTRY_ENVIRONMENT,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
initialized = true;
|
|
39
|
+
logger.info(`[OBSERVABILITY] Sentry enabled [${env.SENTRY_ENVIRONMENT}]`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { Sentry };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
2
|
import { logger } from '@libs/logger.js';
|
|
3
|
+
import { getClientIp } from '@libs/client-ip.js';
|
|
3
4
|
|
|
4
5
|
const colors = {
|
|
5
6
|
// Methods: blue=read, green=create, yellow=update, red=destroy, gray=meta
|
|
@@ -44,17 +45,22 @@ export function markRequestStart(request: FastifyRequest): void {
|
|
|
44
45
|
/**
|
|
45
46
|
* Log a single-line request/response summary (called in onResponse)
|
|
46
47
|
*
|
|
47
|
-
* Format: GET /api/v1/auth/me 200 OK (12ms)
|
|
48
|
+
* Format: GET /api/v1/auth/me 200 OK (12ms) [ip=203.0.113.7 reqId=<uuid>]
|
|
49
|
+
*
|
|
50
|
+
* The IP is the Cloudflare-/proxy-aware resolved client IP (getClientIp), so
|
|
51
|
+
* behind Cloudflare we log the CF-Connecting-IP, not the shared edge IP. reqId
|
|
52
|
+
* is request.id (inbound X-Request-Id or the minted UUID) for log↔Sentry tie-up.
|
|
48
53
|
*/
|
|
49
54
|
export function logRequestLine(request: FastifyRequest, reply: FastifyReply): void {
|
|
50
55
|
const duration = request.startTime ? Date.now() - request.startTime : 0;
|
|
51
56
|
const statusCode = reply.statusCode;
|
|
52
57
|
const statusMessage = reply.raw.statusMessage || '';
|
|
58
|
+
const clientIp = getClientIp(request);
|
|
53
59
|
|
|
54
60
|
const methodColor = colors[request.method as keyof typeof colors] || colors.reset;
|
|
55
61
|
const statusColor = getStatusColor(statusCode);
|
|
56
62
|
|
|
57
|
-
const line = `${methodColor}${request.method.padEnd(7)}${colors.reset}${request.url} ${statusColor}${statusCode} ${statusMessage}${colors.reset} ${colors.dim}(${formatDuration(duration)})${colors.reset}`;
|
|
63
|
+
const line = `${methodColor}${request.method.padEnd(7)}${colors.reset}${request.url} ${statusColor}${statusCode} ${statusMessage}${colors.reset} ${colors.dim}(${formatDuration(duration)}) [ip=${clientIp} reqId=${request.id}]${colors.reset}`;
|
|
58
64
|
|
|
59
65
|
if (statusCode >= 500) {
|
|
60
66
|
logger.error(line);
|
|
@@ -2,11 +2,25 @@
|
|
|
2
2
|
* File Storage Service
|
|
3
3
|
*
|
|
4
4
|
* Handles local file system operations for user-uploaded files.
|
|
5
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* ── Two-tier storage (security: closes the @fastify/static over-exposure) ──────
|
|
7
|
+
* PUBLIC tier (UPLOAD_PUBLIC_DIR, default <cwd>/uploads/public):
|
|
8
|
+
* uploads/public/users/{userId}/<media-type>/ — avatars and other assets meant
|
|
9
|
+
* to be world-readable. THIS is the only directory @fastify/static serves at
|
|
10
|
+
* /uploads/, so a private file can never leak through the static mount.
|
|
11
|
+
* PRIVATE tier (UPLOAD_PRIVATE_DIR, default <cwd>/uploads/private):
|
|
12
|
+
* uploads/private/users/{userId}/ — sensitive files. Lives OUTSIDE the static
|
|
13
|
+
* root and is reachable ONLY through the auth-gated, owner-scoped streaming
|
|
14
|
+
* route GET /api/v1/files/:filename (see src/modules/files/).
|
|
15
|
+
*
|
|
16
|
+
* The public avatar URL shape is UNCHANGED — `/uploads/users/{id}/avatar/x.webp`
|
|
17
|
+
* — because the static mount serves UPLOAD_PUBLIC_DIR at prefix `/uploads/`, so
|
|
18
|
+
* the file just moves one level down on disk (under public/) with no URL churn.
|
|
6
19
|
*/
|
|
7
20
|
|
|
8
21
|
import fs from 'fs/promises';
|
|
9
22
|
import path from 'path';
|
|
23
|
+
import { env } from '@config/env.js';
|
|
10
24
|
import { logger } from '@libs/logger.js';
|
|
11
25
|
import { InternalError } from '@shared/errors/errors.js';
|
|
12
26
|
import { generateAvatarFilename } from './filename-sanitizer.js';
|
|
@@ -15,28 +29,53 @@ import { generateAvatarFilename } from './filename-sanitizer.js';
|
|
|
15
29
|
* File Storage Service
|
|
16
30
|
*
|
|
17
31
|
* Manages local file storage with per-user directories and SEO-friendly naming.
|
|
18
|
-
* All user media lives under
|
|
32
|
+
* All user media lives under {tier}/users/{userId}/ for easy per-user cleanup.
|
|
19
33
|
*/
|
|
20
34
|
class FileStorageService {
|
|
21
|
-
|
|
22
|
-
private readonly
|
|
35
|
+
/** PUBLIC tier root — served as static files at /uploads/ (world-readable). */
|
|
36
|
+
private readonly publicDir: string;
|
|
37
|
+
/** PRIVATE tier root — NEVER served statically; auth-gated route only. */
|
|
38
|
+
private readonly privateDir: string;
|
|
39
|
+
/** {publicDir}/users — per-user public media. */
|
|
40
|
+
private readonly publicUsersDir: string;
|
|
41
|
+
/** {privateDir}/users — per-user private media. */
|
|
42
|
+
private readonly privateUsersDir: string;
|
|
23
43
|
|
|
24
44
|
constructor() {
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
this.
|
|
45
|
+
// Env-driven base paths, optional with <cwd>/uploads/{public,private}
|
|
46
|
+
// defaults so a clean env still boots (see src/config/env.ts).
|
|
47
|
+
this.publicDir = env.UPLOAD_PUBLIC_DIR ?? path.join(process.cwd(), 'uploads', 'public');
|
|
48
|
+
this.privateDir = env.UPLOAD_PRIVATE_DIR ?? path.join(process.cwd(), 'uploads', 'private');
|
|
49
|
+
this.publicUsersDir = path.join(this.publicDir, 'users');
|
|
50
|
+
this.privateUsersDir = path.join(this.privateDir, 'users');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** PUBLIC tier root — read by app.ts to scope the @fastify/static mount. */
|
|
54
|
+
getPublicDir(): string {
|
|
55
|
+
return this.publicDir;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** PRIVATE tier root — read by the files route for path-containment checks. */
|
|
59
|
+
getPrivateDir(): string {
|
|
60
|
+
return this.privateDir;
|
|
29
61
|
}
|
|
30
62
|
|
|
31
63
|
/**
|
|
32
|
-
* Gets the base directory for a user's media
|
|
64
|
+
* Gets the base PUBLIC directory for a user's media
|
|
33
65
|
*/
|
|
34
66
|
private getUserDir(userId: string): string {
|
|
35
|
-
return path.join(this.
|
|
67
|
+
return path.join(this.publicUsersDir, userId);
|
|
36
68
|
}
|
|
37
69
|
|
|
38
70
|
/**
|
|
39
|
-
* Gets the
|
|
71
|
+
* Gets the PRIVATE per-user directory
|
|
72
|
+
*/
|
|
73
|
+
private getUserPrivateDir(userId: string): string {
|
|
74
|
+
return path.join(this.privateUsersDir, userId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Gets the avatar directory for a user (PUBLIC tier)
|
|
40
79
|
*/
|
|
41
80
|
private getUserAvatarDir(userId: string): string {
|
|
42
81
|
return path.join(this.getUserDir(userId), 'avatar');
|
|
@@ -189,6 +228,88 @@ class FileStorageService {
|
|
|
189
228
|
return `/uploads/users/${userId}/avatar/${filename}`;
|
|
190
229
|
}
|
|
191
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Sanitizes a single-segment filename for safe disk storage.
|
|
233
|
+
*
|
|
234
|
+
* Strips every path component (returns only the basename) and rejects any
|
|
235
|
+
* remaining traversal/separator/null-byte chars, so the result can never
|
|
236
|
+
* escape the user's private directory. This is a storage-side guard; the
|
|
237
|
+
* files route applies its OWN validation + path-containment check on read.
|
|
238
|
+
*
|
|
239
|
+
* @throws InternalError on an empty or unusable name
|
|
240
|
+
*/
|
|
241
|
+
private sanitizePrivateFilename(filename: string): string {
|
|
242
|
+
// Reject obvious traversal/separator/null-byte input up front.
|
|
243
|
+
if (!filename || filename.includes('\0') || filename.includes('/') || filename.includes('\\')) {
|
|
244
|
+
throw new InternalError('Invalid private filename', 'INVALID_FILENAME');
|
|
245
|
+
}
|
|
246
|
+
// Collapse to the basename and drop any leading dots so `..`/`.` can't slip
|
|
247
|
+
// through; keep a conservative allowlist (letters, digits, . _ -).
|
|
248
|
+
const base = path.basename(filename).replace(/^\.+/, '');
|
|
249
|
+
const cleaned = base.replace(/[^a-zA-Z0-9._-]/g, '');
|
|
250
|
+
if (!cleaned) {
|
|
251
|
+
throw new InternalError('Invalid private filename', 'INVALID_FILENAME');
|
|
252
|
+
}
|
|
253
|
+
return cleaned;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Saves a PRIVATE file for a user (PRIVATE tier — never served statically).
|
|
258
|
+
*
|
|
259
|
+
* Writes to UPLOAD_PRIVATE_DIR/users/{userId}/<sanitized-filename>. The file
|
|
260
|
+
* is reachable ONLY through the auth-gated, owner-scoped route
|
|
261
|
+
* GET /api/v1/files/:filename — there is no static mount over this tier.
|
|
262
|
+
*
|
|
263
|
+
* @param userId - User's unique ID (the owner; scopes the directory)
|
|
264
|
+
* @param filename - Desired filename (sanitized to a safe single segment)
|
|
265
|
+
* @param buffer - File contents
|
|
266
|
+
* @returns The sanitized filename and the owner-scoped absolute path
|
|
267
|
+
*/
|
|
268
|
+
async savePrivateFile(
|
|
269
|
+
userId: string,
|
|
270
|
+
filename: string,
|
|
271
|
+
buffer: Buffer
|
|
272
|
+
): Promise<{ filename: string; path: string }> {
|
|
273
|
+
try {
|
|
274
|
+
const safeName = this.sanitizePrivateFilename(filename);
|
|
275
|
+
const userDir = this.getUserPrivateDir(userId);
|
|
276
|
+
await this.ensureDirectoryExists(userDir);
|
|
277
|
+
|
|
278
|
+
const filePath = path.join(userDir, safeName);
|
|
279
|
+
await fs.writeFile(filePath, buffer);
|
|
280
|
+
|
|
281
|
+
logger.info({
|
|
282
|
+
msg: 'Private file saved successfully',
|
|
283
|
+
userId,
|
|
284
|
+
filename: safeName,
|
|
285
|
+
fileSize: buffer.length,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
return { filename: safeName, path: filePath };
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (error instanceof InternalError) throw error;
|
|
291
|
+
logger.error({ err: error, msg: 'Failed to save private file', userId });
|
|
292
|
+
throw new InternalError('Failed to save private file', 'FILE_SAVE_FAILED');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Resolves the absolute path of a PRIVATE file for a user.
|
|
298
|
+
*
|
|
299
|
+
* Owner-scoped by construction: the path is derived from the supplied userId
|
|
300
|
+
* (the AUTHENTICATED user at the call site) — never a client-supplied owner.
|
|
301
|
+
* Returns the path WITHOUT touching disk; the caller checks existence and
|
|
302
|
+
* re-asserts path containment before streaming.
|
|
303
|
+
*
|
|
304
|
+
* @param userId - User's unique ID (the owner)
|
|
305
|
+
* @param filename - File name within the user's private directory
|
|
306
|
+
* @returns Absolute file path under UPLOAD_PRIVATE_DIR/users/{userId}/
|
|
307
|
+
*/
|
|
308
|
+
getPrivateFilePath(userId: string, filename: string): string {
|
|
309
|
+
const safeName = this.sanitizePrivateFilename(filename);
|
|
310
|
+
return path.join(this.getUserPrivateDir(userId), safeName);
|
|
311
|
+
}
|
|
312
|
+
|
|
192
313
|
/**
|
|
193
314
|
* Checks if a file exists
|
|
194
315
|
*
|
|
@@ -239,14 +360,21 @@ class FileStorageService {
|
|
|
239
360
|
/**
|
|
240
361
|
* Initializes the upload directory structure
|
|
241
362
|
*
|
|
242
|
-
* Creates
|
|
243
|
-
* Should be called at application startup.
|
|
363
|
+
* Creates BOTH tiers (public + private) and their per-user subdirectories if
|
|
364
|
+
* they don't exist. Should be called at application startup. Creating the
|
|
365
|
+
* public dir up front matters because @fastify/static is mounted on it.
|
|
244
366
|
*/
|
|
245
367
|
async initialize(): Promise<void> {
|
|
246
368
|
try {
|
|
247
|
-
await this.ensureDirectoryExists(this.
|
|
248
|
-
await this.ensureDirectoryExists(this.
|
|
249
|
-
|
|
369
|
+
await this.ensureDirectoryExists(this.publicDir);
|
|
370
|
+
await this.ensureDirectoryExists(this.publicUsersDir);
|
|
371
|
+
await this.ensureDirectoryExists(this.privateDir);
|
|
372
|
+
await this.ensureDirectoryExists(this.privateUsersDir);
|
|
373
|
+
logger.info({
|
|
374
|
+
msg: 'File storage initialized',
|
|
375
|
+
publicDir: this.publicDir,
|
|
376
|
+
privateDir: this.privateDir,
|
|
377
|
+
});
|
|
250
378
|
} catch (error) {
|
|
251
379
|
logger.error({ err: error, msg: 'Failed to initialize file storage' });
|
|
252
380
|
throw new InternalError('Failed to initialize file storage', 'STORAGE_INIT_FAILED');
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin role-gate integration test against REAL containers.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the admin-gated route (`GET /api/v1/admin/stats`, behind
|
|
5
|
+
* `authenticate` + `authorize('ADMIN')`) at all three gates:
|
|
6
|
+
* - 401 unauthenticated
|
|
7
|
+
* - 403 as a normal logged-in USER
|
|
8
|
+
* - 200 as an ADMIN
|
|
9
|
+
*
|
|
10
|
+
* The register endpoint cannot mint admins, so we SEED an ADMIN User row
|
|
11
|
+
* directly via prisma against the container DB, hashing the password with the
|
|
12
|
+
* server's own `@libs/password` lib so the real login path verifies it.
|
|
13
|
+
*
|
|
14
|
+
* Env-before-import: `@/app`, `@/libs/prisma`, `@/libs/password` are all
|
|
15
|
+
* imported LAZILY inside `beforeAll`, after integration.setup.ts has populated
|
|
16
|
+
* process.env with the container URLs.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
19
|
+
import type { FastifyInstance } from 'fastify';
|
|
20
|
+
|
|
21
|
+
let app: FastifyInstance;
|
|
22
|
+
let prisma: typeof import('@/libs/prisma.js')['prisma'];
|
|
23
|
+
|
|
24
|
+
function extractCookie(setCookie: string[] | string | undefined, name: string): string | undefined {
|
|
25
|
+
const headers = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
|
|
26
|
+
for (const header of headers) {
|
|
27
|
+
const first = header.split(';')[0];
|
|
28
|
+
const eq = first.indexOf('=');
|
|
29
|
+
if (eq === -1) continue;
|
|
30
|
+
if (first.slice(0, eq).trim() === name) {
|
|
31
|
+
return first.slice(eq + 1).trim();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function loginAndGetAccessCookie(email: string, password: string): Promise<string> {
|
|
38
|
+
const res = await app.inject({
|
|
39
|
+
method: 'POST',
|
|
40
|
+
url: '/api/v1/auth/login',
|
|
41
|
+
payload: { email, password },
|
|
42
|
+
});
|
|
43
|
+
expect(res.statusCode, `login for ${email} should succeed`).toBe(200);
|
|
44
|
+
const cookie = extractCookie(res.headers['set-cookie'], 'access_token');
|
|
45
|
+
expect(cookie, `login for ${email} must set an access_token cookie`).toBeTruthy();
|
|
46
|
+
return cookie!;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const adminCreds = { email: 'admin-it@example.com', password: 'AdminPass123!' };
|
|
50
|
+
const userCreds = {
|
|
51
|
+
email: 'normal-it@example.com',
|
|
52
|
+
password: 'UserPass123!',
|
|
53
|
+
firstName: 'Normal',
|
|
54
|
+
lastName: 'User',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
let adminCookie: string;
|
|
58
|
+
let userCookie: string;
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
const [{ buildApp }, prismaMod, { hashPassword }] = await Promise.all([
|
|
62
|
+
import('@/app.js'),
|
|
63
|
+
import('@/libs/prisma.js'),
|
|
64
|
+
import('@/libs/password.js'),
|
|
65
|
+
]);
|
|
66
|
+
prisma = prismaMod.prisma;
|
|
67
|
+
|
|
68
|
+
app = await buildApp();
|
|
69
|
+
await app.ready();
|
|
70
|
+
|
|
71
|
+
// Seed an ADMIN directly — the register endpoint only mints USER role.
|
|
72
|
+
await prisma.user.create({
|
|
73
|
+
data: {
|
|
74
|
+
email: adminCreds.email,
|
|
75
|
+
password: await hashPassword(adminCreds.password),
|
|
76
|
+
firstName: 'Admin',
|
|
77
|
+
lastName: 'It',
|
|
78
|
+
role: 'ADMIN',
|
|
79
|
+
isActive: true,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Create the normal user via the real register endpoint.
|
|
84
|
+
const reg = await app.inject({
|
|
85
|
+
method: 'POST',
|
|
86
|
+
url: '/api/v1/auth/register',
|
|
87
|
+
payload: userCreds,
|
|
88
|
+
});
|
|
89
|
+
expect(reg.statusCode).toBe(201);
|
|
90
|
+
|
|
91
|
+
adminCookie = await loginAndGetAccessCookie(adminCreds.email, adminCreds.password);
|
|
92
|
+
userCookie = await loginAndGetAccessCookie(userCreds.email, userCreds.password);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterAll(async () => {
|
|
96
|
+
await app?.close();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('admin role gate (real containers)', () => {
|
|
100
|
+
const ADMIN_ROUTE = '/api/v1/admin/stats';
|
|
101
|
+
|
|
102
|
+
it('rejects an UNAUTHENTICATED request (401)', async () => {
|
|
103
|
+
const res = await app.inject({ method: 'GET', url: ADMIN_ROUTE });
|
|
104
|
+
expect(res.statusCode).toBe(401);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('rejects a normal logged-in USER (403)', async () => {
|
|
108
|
+
const res = await app.inject({
|
|
109
|
+
method: 'GET',
|
|
110
|
+
url: ADMIN_ROUTE,
|
|
111
|
+
cookies: { access_token: userCookie },
|
|
112
|
+
});
|
|
113
|
+
expect(res.statusCode).toBe(403);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('allows an ADMIN (200)', async () => {
|
|
117
|
+
const res = await app.inject({
|
|
118
|
+
method: 'GET',
|
|
119
|
+
url: ADMIN_ROUTE,
|
|
120
|
+
cookies: { access_token: adminCookie },
|
|
121
|
+
});
|
|
122
|
+
expect(res.statusCode).toBe(200);
|
|
123
|
+
const body = res.json();
|
|
124
|
+
expect(body.success).toBe(true);
|
|
125
|
+
// Dashboard stats shape (see admin.controller.getDashboardStats).
|
|
126
|
+
expect(body.data).toHaveProperty('totalUsers');
|
|
127
|
+
});
|
|
128
|
+
});
|