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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Money-path integration test — auth flow against REAL containers.
|
|
3
|
+
*
|
|
4
|
+
* No payments module exists in the scaffold, so the auth flow (register → login →
|
|
5
|
+
* authenticated read) is the money-adjacent critical path. This drives the REAL
|
|
6
|
+
* Fastify app via `app.inject()` against a real MySQL 8 + Redis booted by
|
|
7
|
+
* `src/test/integration.setup.ts`.
|
|
8
|
+
*
|
|
9
|
+
* Env-before-import: `@/app` (which pulls `env.ts` + prisma) is imported LAZILY
|
|
10
|
+
* inside `beforeAll`, AFTER the setup file has populated process.env with the
|
|
11
|
+
* container URLs. See integration.setup.ts for the full rationale.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
14
|
+
import type { FastifyInstance } from 'fastify';
|
|
15
|
+
|
|
16
|
+
let app: FastifyInstance;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pull the `access_token` cookie value out of a set-cookie header array.
|
|
20
|
+
* The login/register controllers set `access_token`, `refresh_token`, and a
|
|
21
|
+
* non-httpOnly `auth_session` indicator (see src/libs/cookies.ts).
|
|
22
|
+
*/
|
|
23
|
+
function extractCookie(setCookie: string[] | string | undefined, name: string): string | undefined {
|
|
24
|
+
const headers = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
|
|
25
|
+
for (const header of headers) {
|
|
26
|
+
const first = header.split(';')[0];
|
|
27
|
+
const eq = first.indexOf('=');
|
|
28
|
+
if (eq === -1) continue;
|
|
29
|
+
if (first.slice(0, eq).trim() === name) {
|
|
30
|
+
return first.slice(eq + 1).trim();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const validUser = {
|
|
37
|
+
email: 'moneypath@example.com',
|
|
38
|
+
password: 'Password123!',
|
|
39
|
+
firstName: 'Money',
|
|
40
|
+
lastName: 'Path',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
// LAZY import — env is now populated by integration.setup.ts.
|
|
45
|
+
const { buildApp } = await import('@/app.js');
|
|
46
|
+
app = await buildApp();
|
|
47
|
+
await app.ready();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
await app?.close();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('auth money-path (real containers)', () => {
|
|
55
|
+
let accessCookie: string | undefined;
|
|
56
|
+
|
|
57
|
+
it('POST /api/v1/auth/register with a valid body succeeds (201) and returns the user', async () => {
|
|
58
|
+
const res = await app.inject({
|
|
59
|
+
method: 'POST',
|
|
60
|
+
url: '/api/v1/auth/register',
|
|
61
|
+
payload: validUser,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(res.statusCode).toBe(201);
|
|
65
|
+
const body = res.json();
|
|
66
|
+
expect(body.success).toBe(true);
|
|
67
|
+
expect(body.data.user.email).toBe(validUser.email);
|
|
68
|
+
expect(body.data.user.role).toBe('USER');
|
|
69
|
+
// Password must never be echoed back.
|
|
70
|
+
expect(body.data.user.password).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('POST /api/v1/auth/register with an INVALID body is rejected (400, Zod validation)', async () => {
|
|
74
|
+
const res = await app.inject({
|
|
75
|
+
method: 'POST',
|
|
76
|
+
url: '/api/v1/auth/register',
|
|
77
|
+
payload: { email: 'not-an-email', password: 'short', firstName: 'A', lastName: '' },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// fastify-type-provider-zod surfaces body validation as a Fastify
|
|
81
|
+
// validation error → the app's error handler maps it to 400 BAD_REQUEST.
|
|
82
|
+
expect(res.statusCode).toBe(400);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('POST /api/v1/auth/login with valid credentials succeeds and sets an access_token cookie', async () => {
|
|
86
|
+
const res = await app.inject({
|
|
87
|
+
method: 'POST',
|
|
88
|
+
url: '/api/v1/auth/login',
|
|
89
|
+
payload: { email: validUser.email, password: validUser.password },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(res.statusCode).toBe(200);
|
|
93
|
+
const body = res.json();
|
|
94
|
+
expect(body.success).toBe(true);
|
|
95
|
+
expect(body.data.user.email).toBe(validUser.email);
|
|
96
|
+
|
|
97
|
+
const setCookie = res.headers['set-cookie'];
|
|
98
|
+
accessCookie = extractCookie(setCookie, 'access_token');
|
|
99
|
+
expect(accessCookie, 'login must set an access_token cookie').toBeTruthy();
|
|
100
|
+
// The refresh cookie is also set (path-scoped to /api/v1/auth).
|
|
101
|
+
expect(extractCookie(setCookie, 'refresh_token')).toBeTruthy();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('POST /api/v1/auth/login with a wrong password fails (401)', async () => {
|
|
105
|
+
const res = await app.inject({
|
|
106
|
+
method: 'POST',
|
|
107
|
+
url: '/api/v1/auth/login',
|
|
108
|
+
payload: { email: validUser.email, password: 'WrongPassword123!' },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(res.statusCode).toBe(401);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('GET /api/v1/auth/me WITHOUT the cookie is rejected (401)', async () => {
|
|
115
|
+
const res = await app.inject({
|
|
116
|
+
method: 'GET',
|
|
117
|
+
url: '/api/v1/auth/me',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(res.statusCode).toBe(401);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('GET /api/v1/auth/me WITH the access_token cookie returns 200 and the right user', async () => {
|
|
124
|
+
expect(accessCookie, 'login test must have captured the cookie first').toBeTruthy();
|
|
125
|
+
|
|
126
|
+
const res = await app.inject({
|
|
127
|
+
method: 'GET',
|
|
128
|
+
url: '/api/v1/auth/me',
|
|
129
|
+
cookies: { access_token: accessCookie! },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(res.statusCode).toBe(200);
|
|
133
|
+
const body = res.json();
|
|
134
|
+
expect(body.success).toBe(true);
|
|
135
|
+
expect(body.data.email).toBe(validUser.email);
|
|
136
|
+
expect(body.data.role).toBe('USER');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -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';
|