create-tigra 3.0.0 → 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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/template/client/.env.example +19 -0
  3. package/template/client/Dockerfile +3 -3
  4. package/template/client/next.config.ts +7 -42
  5. package/template/client/package-lock.json +1896 -146
  6. package/template/client/package.json +2 -0
  7. package/template/client/src/app/layout.tsx +10 -3
  8. package/template/client/src/app/providers.tsx +15 -2
  9. package/template/client/src/instrumentation-client.ts +30 -0
  10. package/template/client/src/instrumentation.ts +38 -0
  11. package/template/client/src/lib/env.ts +13 -2
  12. package/template/client/src/middleware.ts +105 -18
  13. package/template/server/.env.example +269 -236
  14. package/template/server/.env.example.production +236 -208
  15. package/template/server/Dockerfile +7 -5
  16. package/template/server/docker-compose.yml +17 -0
  17. package/template/server/package-lock.json +2136 -86
  18. package/template/server/package.json +6 -0
  19. package/template/server/src/app.ts +335 -303
  20. package/template/server/src/config/env.ts +171 -143
  21. package/template/server/src/config/rate-limit.config.ts +6 -0
  22. package/template/server/src/libs/__tests__/auth-path.test.ts +24 -0
  23. package/template/server/src/libs/__tests__/client-ip.test.ts +121 -0
  24. package/template/server/src/libs/__tests__/ip-block.test.ts +62 -0
  25. package/template/server/src/libs/__tests__/url-safety.test.ts +80 -0
  26. package/template/server/src/libs/auth-path.ts +14 -0
  27. package/template/server/src/libs/client-ip.ts +77 -0
  28. package/template/server/src/libs/ip-block.ts +220 -212
  29. package/template/server/src/libs/logger.ts +15 -0
  30. package/template/server/src/libs/observability/sentry.ts +42 -0
  31. package/template/server/src/libs/query-counter.ts +59 -0
  32. package/template/server/src/libs/requestLogger.ts +8 -2
  33. package/template/server/src/libs/storage/file-storage.service.ts +144 -16
  34. package/template/server/src/libs/url-safety.ts +121 -0
  35. package/template/server/src/modules/admin/__tests__/admin.integration.test.ts +128 -0
  36. package/template/server/src/modules/auth/__tests__/auth.integration.test.ts +138 -0
  37. package/template/server/src/modules/auth/auth.controller.ts +128 -127
  38. package/template/server/src/modules/files/__tests__/files.integration.test.ts +157 -0
  39. package/template/server/src/modules/files/files.controller.ts +180 -0
  40. package/template/server/src/modules/files/files.routes.ts +46 -0
  41. package/template/server/src/server.ts +6 -0
  42. package/template/server/src/test/integration.setup.ts +170 -0
  43. package/template/server/vitest.config.ts +10 -1
  44. package/template/server/vitest.integration.config.ts +50 -0
@@ -1,127 +1,128 @@
1
- import type { FastifyRequest, FastifyReply } from 'fastify';
2
- import { successResponse } from '@shared/responses/successResponse.js';
3
- import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
4
- import { setAuthCookies, clearAuthCookies } from '@libs/cookies.js';
5
- import type {
6
- RegisterInput,
7
- LoginInput,
8
- ForgotPasswordInput,
9
- ResetPasswordInput,
10
- } from './auth.schemas.js';
11
- import * as authService from './auth.service.js';
12
-
13
- export async function register(
14
- request: FastifyRequest<{ Body: RegisterInput }>,
15
- reply: FastifyReply,
16
- ): Promise<void> {
17
- const deviceInfo = request.headers['user-agent'];
18
- const ipAddress = request.ip;
19
-
20
- const result = await authService.register(request.body, deviceInfo, ipAddress);
21
-
22
- if (result.requiresVerification) {
23
- reply.status(201).send(
24
- successResponse('Registration successful. Please verify your account to continue.', { user: result.user }),
25
- );
26
- return;
27
- }
28
-
29
- setAuthCookies(reply, result.accessToken!, result.refreshToken!);
30
- reply.status(201).send(successResponse('User registered successfully', { user: result.user }));
31
- }
32
-
33
- export async function login(
34
- request: FastifyRequest<{ Body: LoginInput }>,
35
- reply: FastifyReply,
36
- ): Promise<void> {
37
- const deviceInfo = request.headers['user-agent'];
38
- const ipAddress = request.ip;
39
-
40
- const result = await authService.login(request.body, deviceInfo, ipAddress);
41
-
42
- setAuthCookies(reply, result.accessToken, result.refreshToken);
43
- reply.send(successResponse('Logged in successfully', { user: result.user }));
44
- }
45
-
46
- export async function refresh(
47
- request: FastifyRequest,
48
- reply: FastifyReply,
49
- ): Promise<void> {
50
- const refreshToken = request.cookies.refresh_token;
51
- if (!refreshToken) {
52
- clearAuthCookies(reply);
53
- throw new UnauthorizedError('Refresh token not provided', 'MISSING_REFRESH_TOKEN');
54
- }
55
-
56
- try {
57
- const tokens = await authService.refresh(refreshToken);
58
- setAuthCookies(reply, tokens.accessToken, tokens.refreshToken);
59
- reply.send(successResponse('Token refreshed successfully', null));
60
- } catch (error) {
61
- // Session is definitively dead (refresh token revoked, user gone, inactive).
62
- // Clear all auth cookies so the browser stops replaying them — otherwise the
63
- // client keeps retrying refresh and middleware keeps redirecting, producing
64
- // an infinite loop that trips the rate limiter.
65
- if (error instanceof UnauthorizedError || error instanceof ForbiddenError) {
66
- clearAuthCookies(reply);
67
- }
68
- throw error;
69
- }
70
- }
71
-
72
- export async function logout(
73
- request: FastifyRequest,
74
- reply: FastifyReply,
75
- ): Promise<void> {
76
- const refreshToken = request.cookies.refresh_token;
77
- if (refreshToken) {
78
- await authService.logout(refreshToken);
79
- }
80
-
81
- clearAuthCookies(reply);
82
- reply.send(successResponse('Logged out successfully', null));
83
- }
84
-
85
- export async function me(
86
- request: FastifyRequest,
87
- reply: FastifyReply,
88
- ): Promise<void> {
89
- const user = await authService.getCurrentUser(request.user.userId);
90
- reply.send(successResponse('Current user retrieved', user));
91
- }
92
-
93
- export async function getSessions(
94
- request: FastifyRequest,
95
- reply: FastifyReply,
96
- ): Promise<void> {
97
- const sessions = await authService.getUserSessions(request.user.userId);
98
- reply.send(successResponse('User sessions retrieved', sessions));
99
- }
100
-
101
- export async function logoutAllSessions(
102
- request: FastifyRequest,
103
- reply: FastifyReply,
104
- ): Promise<void> {
105
- const count = await authService.logoutAllSessions(request.user.userId);
106
- clearAuthCookies(reply);
107
- reply.send(
108
- successResponse(`Successfully logged out from ${count} session(s)`, { count }),
109
- );
110
- }
111
-
112
- export async function forgotPassword(
113
- request: FastifyRequest<{ Body: ForgotPasswordInput }>,
114
- reply: FastifyReply,
115
- ): Promise<void> {
116
- await authService.forgotPassword(request.body.email);
117
- // Always return success to prevent email enumeration
118
- reply.send(successResponse('If an account exists with that email, a reset link has been sent', null));
119
- }
120
-
121
- export async function resetPassword(
122
- request: FastifyRequest<{ Body: ResetPasswordInput }>,
123
- reply: FastifyReply,
124
- ): Promise<void> {
125
- await authService.resetPassword(request.body.token, request.body.newPassword);
126
- reply.send(successResponse('Password reset successfully', null));
127
- }
1
+ import type { FastifyRequest, FastifyReply } from 'fastify';
2
+ import { successResponse } from '@shared/responses/successResponse.js';
3
+ import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
4
+ import { setAuthCookies, clearAuthCookies } from '@libs/cookies.js';
5
+ import { getClientIp } from '@libs/client-ip.js';
6
+ import type {
7
+ RegisterInput,
8
+ LoginInput,
9
+ ForgotPasswordInput,
10
+ ResetPasswordInput,
11
+ } from './auth.schemas.js';
12
+ import * as authService from './auth.service.js';
13
+
14
+ export async function register(
15
+ request: FastifyRequest<{ Body: RegisterInput }>,
16
+ reply: FastifyReply,
17
+ ): Promise<void> {
18
+ const deviceInfo = request.headers['user-agent'];
19
+ const ipAddress = getClientIp(request);
20
+
21
+ const result = await authService.register(request.body, deviceInfo, ipAddress);
22
+
23
+ if (result.requiresVerification) {
24
+ reply.status(201).send(
25
+ successResponse('Registration successful. Please verify your account to continue.', { user: result.user }),
26
+ );
27
+ return;
28
+ }
29
+
30
+ setAuthCookies(reply, result.accessToken!, result.refreshToken!);
31
+ reply.status(201).send(successResponse('User registered successfully', { user: result.user }));
32
+ }
33
+
34
+ export async function login(
35
+ request: FastifyRequest<{ Body: LoginInput }>,
36
+ reply: FastifyReply,
37
+ ): Promise<void> {
38
+ const deviceInfo = request.headers['user-agent'];
39
+ const ipAddress = getClientIp(request);
40
+
41
+ const result = await authService.login(request.body, deviceInfo, ipAddress);
42
+
43
+ setAuthCookies(reply, result.accessToken, result.refreshToken);
44
+ reply.send(successResponse('Logged in successfully', { user: result.user }));
45
+ }
46
+
47
+ export async function refresh(
48
+ request: FastifyRequest,
49
+ reply: FastifyReply,
50
+ ): Promise<void> {
51
+ const refreshToken = request.cookies.refresh_token;
52
+ if (!refreshToken) {
53
+ clearAuthCookies(reply);
54
+ throw new UnauthorizedError('Refresh token not provided', 'MISSING_REFRESH_TOKEN');
55
+ }
56
+
57
+ try {
58
+ const tokens = await authService.refresh(refreshToken);
59
+ setAuthCookies(reply, tokens.accessToken, tokens.refreshToken);
60
+ reply.send(successResponse('Token refreshed successfully', null));
61
+ } catch (error) {
62
+ // Session is definitively dead (refresh token revoked, user gone, inactive).
63
+ // Clear all auth cookies so the browser stops replaying them — otherwise the
64
+ // client keeps retrying refresh and middleware keeps redirecting, producing
65
+ // an infinite loop that trips the rate limiter.
66
+ if (error instanceof UnauthorizedError || error instanceof ForbiddenError) {
67
+ clearAuthCookies(reply);
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ export async function logout(
74
+ request: FastifyRequest,
75
+ reply: FastifyReply,
76
+ ): Promise<void> {
77
+ const refreshToken = request.cookies.refresh_token;
78
+ if (refreshToken) {
79
+ await authService.logout(refreshToken);
80
+ }
81
+
82
+ clearAuthCookies(reply);
83
+ reply.send(successResponse('Logged out successfully', null));
84
+ }
85
+
86
+ export async function me(
87
+ request: FastifyRequest,
88
+ reply: FastifyReply,
89
+ ): Promise<void> {
90
+ const user = await authService.getCurrentUser(request.user.userId);
91
+ reply.send(successResponse('Current user retrieved', user));
92
+ }
93
+
94
+ export async function getSessions(
95
+ request: FastifyRequest,
96
+ reply: FastifyReply,
97
+ ): Promise<void> {
98
+ const sessions = await authService.getUserSessions(request.user.userId);
99
+ reply.send(successResponse('User sessions retrieved', sessions));
100
+ }
101
+
102
+ export async function logoutAllSessions(
103
+ request: FastifyRequest,
104
+ reply: FastifyReply,
105
+ ): Promise<void> {
106
+ const count = await authService.logoutAllSessions(request.user.userId);
107
+ clearAuthCookies(reply);
108
+ reply.send(
109
+ successResponse(`Successfully logged out from ${count} session(s)`, { count }),
110
+ );
111
+ }
112
+
113
+ export async function forgotPassword(
114
+ request: FastifyRequest<{ Body: ForgotPasswordInput }>,
115
+ reply: FastifyReply,
116
+ ): Promise<void> {
117
+ await authService.forgotPassword(request.body.email);
118
+ // Always return success to prevent email enumeration
119
+ reply.send(successResponse('If an account exists with that email, a reset link has been sent', null));
120
+ }
121
+
122
+ export async function resetPassword(
123
+ request: FastifyRequest<{ Body: ResetPasswordInput }>,
124
+ reply: FastifyReply,
125
+ ): Promise<void> {
126
+ await authService.resetPassword(request.body.token, request.body.newPassword);
127
+ reply.send(successResponse('Password reset successfully', null));
128
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Files-route integration test — the load-bearing proof that the two-tier
3
+ * upload split (P0-2) closes the @fastify/static over-exposure.
4
+ *
5
+ * Drives the REAL Fastify app via `app.inject()` against the MySQL 8 + Redis
6
+ * booted by `src/test/integration.setup.ts`. The setup file points
7
+ * UPLOAD_PUBLIC_DIR / UPLOAD_PRIVATE_DIR at isolated temp dirs (exported via
8
+ * `containerInfo`) BEFORE env.ts is evaluated, so this test controls the files
9
+ * on disk and asserts exactly what is and isn't reachable.
10
+ *
11
+ * What this proves:
12
+ * 1. PUBLIC static still works WITHOUT auth (avatar URL shape unchanged).
13
+ * 2. EXPOSURE CLOSED — a file in the PRIVATE tier is NOT reachable via the
14
+ * static /uploads/... path (404).
15
+ * 3. The auth-gated route: 401 without a cookie, 200 + bytes with one, 206 +
16
+ * Content-Range for a Range request, 404 for a traversal attempt.
17
+ *
18
+ * Env-before-import: `@/app` is imported LAZILY inside `beforeAll`, after the
19
+ * setup file has populated process.env. See integration.setup.ts.
20
+ */
21
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
22
+ import fs from 'node:fs/promises';
23
+ import path from 'node:path';
24
+ import type { FastifyInstance } from 'fastify';
25
+ import { containerInfo } from '@/test/integration.setup.js';
26
+
27
+ let app: FastifyInstance;
28
+
29
+ /** Pull a named cookie value out of a set-cookie header array. */
30
+ function extractCookie(setCookie: string[] | string | undefined, name: string): string | undefined {
31
+ const headers = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
32
+ for (const header of headers) {
33
+ const first = header.split(';')[0];
34
+ const eq = first.indexOf('=');
35
+ if (eq === -1) continue;
36
+ if (first.slice(0, eq).trim() === name) {
37
+ return first.slice(eq + 1).trim();
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ const filesUser = {
44
+ email: 'files-route@example.com',
45
+ password: 'Password123!',
46
+ firstName: 'Files',
47
+ lastName: 'Route',
48
+ };
49
+
50
+ let accessCookie: string;
51
+ let userId: string;
52
+
53
+ const PRIVATE_BODY = 'the-quick-brown-fox-private-bytes';
54
+
55
+ beforeAll(async () => {
56
+ const { buildApp } = await import('@/app.js');
57
+ app = await buildApp();
58
+ await app.ready();
59
+
60
+ // Register + login to mint an authed cookie and learn the user id.
61
+ const reg = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: filesUser });
62
+ expect(reg.statusCode).toBe(201);
63
+ userId = reg.json().data.user.id as string;
64
+ expect(userId).toBeTruthy();
65
+
66
+ const login = await app.inject({
67
+ method: 'POST',
68
+ url: '/api/v1/auth/login',
69
+ payload: { email: filesUser.email, password: filesUser.password },
70
+ });
71
+ expect(login.statusCode).toBe(200);
72
+ const cookie = extractCookie(login.headers['set-cookie'], 'access_token');
73
+ expect(cookie, 'login must set an access_token cookie').toBeTruthy();
74
+ accessCookie = cookie!;
75
+
76
+ // --- Seed fixtures directly on disk, in the temp tiers the app reads. ---
77
+ // PUBLIC avatar (served statically, no auth).
78
+ const publicAvatarDir = path.join(containerInfo.uploadPublicDir, 'users', userId, 'avatar');
79
+ await fs.mkdir(publicAvatarDir, { recursive: true });
80
+ await fs.writeFile(path.join(publicAvatarDir, 'x.webp'), Buffer.from('PUBLIC-AVATAR-BYTES'));
81
+
82
+ // PRIVATE file (auth-gated route only).
83
+ const privateUserDir = path.join(containerInfo.uploadPrivateDir, 'users', userId);
84
+ await fs.mkdir(privateUserDir, { recursive: true });
85
+ await fs.writeFile(path.join(privateUserDir, 'doc.txt'), Buffer.from(PRIVATE_BODY));
86
+
87
+ // A SECRET file in the private tier, addressable only via traversal from the
88
+ // static root — used to prove the static mount cannot reach the private tier.
89
+ await fs.writeFile(path.join(containerInfo.uploadPrivateDir, 'secret.txt'), Buffer.from('TOP-SECRET'));
90
+ });
91
+
92
+ afterAll(async () => {
93
+ await app?.close();
94
+ });
95
+
96
+ describe('files two-tier uploads (real containers)', () => {
97
+ it('PUBLIC: GET /uploads/users/{id}/avatar/x.webp WITHOUT auth → 200 (static still served)', async () => {
98
+ const res = await app.inject({ method: 'GET', url: `/uploads/users/${userId}/avatar/x.webp` });
99
+ expect(res.statusCode).toBe(200);
100
+ expect(res.body).toContain('PUBLIC-AVATAR-BYTES');
101
+ });
102
+
103
+ it('EXPOSURE CLOSED: a PRIVATE-tier file is NOT reachable via /uploads/... (404)', async () => {
104
+ // Direct static path into the private tier does not exist under the public
105
+ // root, and traversal out of the public root is blocked by @fastify/static.
106
+ const direct = await app.inject({ method: 'GET', url: '/uploads/secret.txt' });
107
+ expect(direct.statusCode).toBe(404);
108
+
109
+ const traversal = await app.inject({
110
+ method: 'GET',
111
+ url: '/uploads/..%2f..%2fprivate%2fsecret.txt',
112
+ });
113
+ // @fastify/static rejects traversal (404/403); the one thing that must
114
+ // never happen is a 200 leaking the private bytes.
115
+ expect(traversal.statusCode).not.toBe(200);
116
+ });
117
+
118
+ it('PRIVATE route WITHOUT auth → 401', async () => {
119
+ const res = await app.inject({ method: 'GET', url: '/api/v1/files/doc.txt' });
120
+ expect(res.statusCode).toBe(401);
121
+ });
122
+
123
+ it('PRIVATE route WITH auth → 200 and the file bytes', async () => {
124
+ const res = await app.inject({
125
+ method: 'GET',
126
+ url: '/api/v1/files/doc.txt',
127
+ cookies: { access_token: accessCookie },
128
+ });
129
+ expect(res.statusCode).toBe(200);
130
+ expect(res.body).toBe(PRIVATE_BODY);
131
+ expect(res.headers['accept-ranges']).toBe('bytes');
132
+ expect(res.headers['cache-control']).toBe('private');
133
+ expect(res.headers['content-type']).toContain('text/plain');
134
+ });
135
+
136
+ it('PRIVATE route with a Range header → 206 + correct Content-Range', async () => {
137
+ const res = await app.inject({
138
+ method: 'GET',
139
+ url: '/api/v1/files/doc.txt',
140
+ cookies: { access_token: accessCookie },
141
+ headers: { range: 'bytes=0-3' },
142
+ });
143
+ expect(res.statusCode).toBe(206);
144
+ expect(res.headers['content-range']).toBe(`bytes 0-3/${PRIVATE_BODY.length}`);
145
+ expect(res.headers['content-length']).toBe('4');
146
+ expect(res.body).toBe(PRIVATE_BODY.slice(0, 4));
147
+ });
148
+
149
+ it('PRIVATE route traversal attempt → 404 (no existence leak, not 403)', async () => {
150
+ const res = await app.inject({
151
+ method: 'GET',
152
+ url: '/api/v1/files/..%2f..%2fsecret',
153
+ cookies: { access_token: accessCookie },
154
+ });
155
+ expect(res.statusCode).toBe(404);
156
+ });
157
+ });
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Files Controller
3
+ *
4
+ * Auth-gated, owner-scoped streaming of PRIVATE-tier files.
5
+ *
6
+ * SECURITY model — three independent guards, any one of which forces a 404
7
+ * (never a 403: we do not leak whether a file exists):
8
+ * 1. Owner-scoped by construction. The on-disk path is built from the
9
+ * AUTHENTICATED user's id (request.user.userId), never a client-supplied
10
+ * owner. A user can therefore only ever read files under their OWN private
11
+ * directory — there is no parameter that selects another user's files.
12
+ * 2. Filename validation. The :filename segment is rejected if it contains a
13
+ * path separator, a `..` traversal, or a null byte.
14
+ * 3. Path containment. After path.resolve, the absolute target must still sit
15
+ * inside the user's resolved private directory — a final belt-and-braces
16
+ * check against any traversal the validation missed.
17
+ *
18
+ * NOTE for apps with a Prisma file/upload model: swap the path-based
19
+ * owner-scoping below for a DB lookup keyed on ownerId — load the row by id,
20
+ * 404 if it doesn't exist OR its ownerId !== request.user.userId, then stream
21
+ * from the stored path. The Range/streaming half of this handler stays the same.
22
+ */
23
+
24
+ import { createReadStream } from 'node:fs';
25
+ import fs from 'node:fs/promises';
26
+ import path from 'node:path';
27
+ import type { FastifyReply } from 'fastify';
28
+ import { fileStorageService } from '@libs/storage/file-storage.service.js';
29
+ import { NotFoundError } from '@shared/errors/errors.js';
30
+ import type { AuthenticatedRequest } from '@shared/types/index.js';
31
+ import { logger } from '@libs/logger.js';
32
+
33
+ /** Map a file extension to a Content-Type. Falls back to octet-stream. */
34
+ const CONTENT_TYPES: Record<string, string> = {
35
+ '.webp': 'image/webp',
36
+ '.png': 'image/png',
37
+ '.jpg': 'image/jpeg',
38
+ '.jpeg': 'image/jpeg',
39
+ '.gif': 'image/gif',
40
+ '.svg': 'image/svg+xml',
41
+ '.pdf': 'application/pdf',
42
+ '.txt': 'text/plain; charset=utf-8',
43
+ '.json': 'application/json; charset=utf-8',
44
+ '.csv': 'text/csv; charset=utf-8',
45
+ '.zip': 'application/zip',
46
+ '.mp4': 'video/mp4',
47
+ '.webm': 'video/webm',
48
+ '.mp3': 'audio/mpeg',
49
+ };
50
+
51
+ function contentTypeFor(filename: string): string {
52
+ return CONTENT_TYPES[path.extname(filename).toLowerCase()] ?? 'application/octet-stream';
53
+ }
54
+
55
+ /** Reject any filename that is not a single safe path segment. */
56
+ function isUnsafeFilename(filename: string): boolean {
57
+ return (
58
+ !filename ||
59
+ filename.includes('\0') ||
60
+ filename.includes('/') ||
61
+ filename.includes('\\') ||
62
+ filename.includes('..')
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Parse a single `bytes=start-end` Range header against a known total size.
68
+ * Returns null when there is no (usable) range and the full file should be
69
+ * sent, or an `unsatisfiable` marker when the range is out of bounds (→ 416).
70
+ */
71
+ function parseRange(
72
+ rangeHeader: string | undefined,
73
+ size: number,
74
+ ): { start: number; end: number } | null | 'unsatisfiable' {
75
+ if (!rangeHeader) return null;
76
+ const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
77
+ if (!match) return null; // unparseable → ignore, serve full body (RFC 7233)
78
+
79
+ const [, startRaw, endRaw] = match;
80
+ if (startRaw === '' && endRaw === '') return null;
81
+
82
+ let start: number;
83
+ let end: number;
84
+ if (startRaw === '') {
85
+ // Suffix range: last N bytes.
86
+ const suffix = Number(endRaw);
87
+ if (suffix <= 0) return 'unsatisfiable';
88
+ start = Math.max(size - suffix, 0);
89
+ end = size - 1;
90
+ } else {
91
+ start = Number(startRaw);
92
+ end = endRaw === '' ? size - 1 : Number(endRaw);
93
+ }
94
+
95
+ if (Number.isNaN(start) || Number.isNaN(end) || start > end || start >= size) {
96
+ return 'unsatisfiable';
97
+ }
98
+ if (end >= size) end = size - 1;
99
+ return { start, end };
100
+ }
101
+
102
+ class FilesController {
103
+ /**
104
+ * GET /api/v1/files/:filename
105
+ *
106
+ * Streams a PRIVATE file owned by the authenticated user. Honors a Range
107
+ * header (206 partial content); otherwise sends the full file (200).
108
+ */
109
+ async getPrivateFile(
110
+ request: AuthenticatedRequest,
111
+ reply: FastifyReply,
112
+ ): Promise<void> {
113
+ const userId = request.user.userId;
114
+ const { filename } = request.params as { filename: string };
115
+
116
+ // Guard 2: filename validation. Any violation → 404 (no existence leak).
117
+ if (isUnsafeFilename(filename)) {
118
+ logger.warn({ msg: 'Rejected unsafe private file name', userId, filename });
119
+ throw new NotFoundError('File not found', 'FILE_NOT_FOUND');
120
+ }
121
+
122
+ // Guard 1: owner-scoped path, derived from the authenticated user id only.
123
+ const filePath = fileStorageService.getPrivateFilePath(userId, filename);
124
+
125
+ // Guard 3: path containment. The resolved absolute path must remain inside
126
+ // the user's resolved private directory.
127
+ const userPrivateRoot = path.resolve(
128
+ fileStorageService.getPrivateDir(),
129
+ 'users',
130
+ userId,
131
+ );
132
+ const resolved = path.resolve(filePath);
133
+ if (resolved !== userPrivateRoot && !resolved.startsWith(userPrivateRoot + path.sep)) {
134
+ logger.warn({ msg: 'Private file path escaped owner root', userId, filename });
135
+ throw new NotFoundError('File not found', 'FILE_NOT_FOUND');
136
+ }
137
+
138
+ // Stat the file — missing/inaccessible/dir → 404 (no existence leak).
139
+ let stat;
140
+ try {
141
+ stat = await fs.stat(resolved);
142
+ } catch {
143
+ throw new NotFoundError('File not found', 'FILE_NOT_FOUND');
144
+ }
145
+ if (!stat.isFile()) {
146
+ throw new NotFoundError('File not found', 'FILE_NOT_FOUND');
147
+ }
148
+
149
+ const size = stat.size;
150
+ const contentType = contentTypeFor(filename);
151
+
152
+ reply.header('Accept-Ranges', 'bytes');
153
+ reply.header('Content-Type', contentType);
154
+ // Private files must never be cached by shared/proxy caches.
155
+ reply.header('Cache-Control', 'private');
156
+
157
+ const range = parseRange(request.headers.range, size);
158
+
159
+ if (range === 'unsatisfiable') {
160
+ reply.header('Content-Range', `bytes */${size}`);
161
+ reply.code(416);
162
+ return reply.send('Range Not Satisfiable');
163
+ }
164
+
165
+ if (range) {
166
+ const { start, end } = range;
167
+ reply.code(206);
168
+ reply.header('Content-Range', `bytes ${start}-${end}/${size}`);
169
+ reply.header('Content-Length', String(end - start + 1));
170
+ return reply.send(createReadStream(resolved, { start, end }));
171
+ }
172
+
173
+ // No range → full file.
174
+ reply.code(200);
175
+ reply.header('Content-Length', String(size));
176
+ return reply.send(createReadStream(resolved));
177
+ }
178
+ }
179
+
180
+ export const filesController = new FilesController();
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Files Routes
3
+ *
4
+ * Auth-gated streaming of PRIVATE-tier files. This is the ONLY way to read a
5
+ * file stored under UPLOAD_PRIVATE_DIR — that directory is deliberately NOT
6
+ * served by @fastify/static (see app.ts), so a private file can never leak
7
+ * through /uploads/*.
8
+ */
9
+
10
+ import type { FastifyInstance } from 'fastify';
11
+ import { z } from 'zod';
12
+ import { filesController } from './files.controller.js';
13
+ import { authenticate } from '@libs/auth.js';
14
+ import { RATE_LIMITS } from '@config/rate-limit.config.js';
15
+
16
+ // Params are intentionally validated as a plain string here — the security
17
+ // checks (separator / `..` / null-byte rejection + path containment) live in
18
+ // the controller so a violation maps to 404 (no existence leak) rather than the
19
+ // 400 a Zod-level regex rejection would produce.
20
+ const GetPrivateFileParamsSchema = z.object({
21
+ filename: z.string().min(1),
22
+ });
23
+
24
+ export async function filesRoutes(fastify: FastifyInstance): Promise<void> {
25
+ /**
26
+ * Get a private file (owner-scoped, auth required)
27
+ *
28
+ * GET /api/v1/files/:filename
29
+ * Auth: Required (JWT) — 401 if unauthenticated.
30
+ * Serves UPLOAD_PRIVATE_DIR/users/{authenticatedUserId}/:filename.
31
+ * Honors a Range header (206 partial content); 404 on any miss/violation.
32
+ */
33
+ fastify.get<{ Params: { filename: string } }>(
34
+ '/files/:filename',
35
+ {
36
+ preValidation: [authenticate],
37
+ schema: {
38
+ params: GetPrivateFileParamsSchema,
39
+ },
40
+ config: {
41
+ rateLimit: RATE_LIMITS.FILES_GET,
42
+ },
43
+ },
44
+ filesController.getPrivateFile.bind(filesController),
45
+ );
46
+ }
@@ -1,3 +1,9 @@
1
+ // Initialize Sentry as early as possible — before the app (and its routes) are
2
+ // imported/built — so error capture is active before any request is handled.
3
+ // Inert no-op when SENTRY_DSN is unset. See src/libs/observability/sentry.ts.
4
+ import { initSentry } from '@libs/observability/sentry.js';
5
+ initSentry();
6
+
1
7
  import { env } from '@config/env.js';
2
8
  import { logger } from '@libs/logger.js';
3
9
  import { testDatabaseConnection, disconnectPrisma } from '@libs/prisma.js';