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
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
"start": "next start",
|
|
9
9
|
"lint": "eslint src/",
|
|
10
10
|
"typecheck": "tsc --noEmit",
|
|
11
|
+
"audit": "npm audit --audit-level=high",
|
|
11
12
|
"generate:env": "node -e \"import('fs').then(f=>{if(f.existsSync('.env'))console.log('.env already exists, skipping');else{f.copyFileSync('.env.example','.env');console.log('.env created from .env.example')}})\""
|
|
12
13
|
},
|
|
13
14
|
"dependencies": {
|
|
14
15
|
"@hookform/resolvers": "^5.2.2",
|
|
15
16
|
"@reduxjs/toolkit": "^2.11.2",
|
|
17
|
+
"@sentry/nextjs": "^10.62.0",
|
|
16
18
|
"@tanstack/react-query": "^5.90.21",
|
|
17
19
|
"axios": "^1.13.5",
|
|
18
20
|
"class-variance-authority": "^0.7.1",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { headers } from 'next/headers';
|
|
1
2
|
import type { Metadata, Viewport } from 'next';
|
|
2
3
|
import type React from 'react';
|
|
3
4
|
import { Inter, JetBrains_Mono } from 'next/font/google';
|
|
@@ -27,15 +28,21 @@ export const viewport: Viewport = {
|
|
|
27
28
|
viewportFit: 'cover',
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
export default function RootLayout({
|
|
31
|
+
export default async function RootLayout({
|
|
31
32
|
children,
|
|
32
33
|
}: Readonly<{
|
|
33
34
|
children: React.ReactNode;
|
|
34
|
-
}>): React.ReactElement {
|
|
35
|
+
}>): Promise<React.ReactElement> {
|
|
36
|
+
// Read the per-request nonce set by middleware. next-themes injects an inline
|
|
37
|
+
// anti-FOUC <script> via dangerouslySetInnerHTML that Next does NOT auto-nonce,
|
|
38
|
+
// so under our 'strict-dynamic' CSP it must be passed the nonce explicitly or
|
|
39
|
+
// the browser refuses it (causing a flash / hydration mismatch).
|
|
40
|
+
const nonce = (await headers()).get('x-nonce') ?? undefined;
|
|
41
|
+
|
|
35
42
|
return (
|
|
36
43
|
<html lang="en" suppressHydrationWarning>
|
|
37
44
|
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}>
|
|
38
|
-
<Providers>{children}</Providers>
|
|
45
|
+
<Providers nonce={nonce}>{children}</Providers>
|
|
39
46
|
</body>
|
|
40
47
|
</html>
|
|
41
48
|
);
|
|
@@ -11,7 +11,15 @@ import { Toaster } from 'sonner';
|
|
|
11
11
|
import { store } from '@/store';
|
|
12
12
|
import { AuthInitializer } from '@/features/auth/components/AuthInitializer';
|
|
13
13
|
|
|
14
|
-
export function Providers({
|
|
14
|
+
export function Providers({
|
|
15
|
+
children,
|
|
16
|
+
nonce,
|
|
17
|
+
}: {
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
// Per-request CSP nonce from middleware (via the root layout). Forwarded to
|
|
20
|
+
// next-themes so its inline anti-FOUC script is allowed under 'strict-dynamic'.
|
|
21
|
+
nonce?: string;
|
|
22
|
+
}): React.ReactElement {
|
|
15
23
|
const [queryClient] = useState(
|
|
16
24
|
() =>
|
|
17
25
|
new QueryClient({
|
|
@@ -30,7 +38,12 @@ export function Providers({ children }: { children: React.ReactNode }): React.Re
|
|
|
30
38
|
return (
|
|
31
39
|
<ReduxProvider store={store}>
|
|
32
40
|
<QueryClientProvider client={queryClient}>
|
|
33
|
-
<ThemeProvider
|
|
41
|
+
<ThemeProvider
|
|
42
|
+
attribute="class"
|
|
43
|
+
defaultTheme="light"
|
|
44
|
+
enableColorScheme={false}
|
|
45
|
+
nonce={nonce}
|
|
46
|
+
>
|
|
34
47
|
<AuthInitializer>
|
|
35
48
|
{children}
|
|
36
49
|
</AuthInitializer>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser (client) instrumentation — env-gated Sentry, INERT by default.
|
|
3
|
+
*
|
|
4
|
+
* Next.js 15.3+/16 loads this module in the browser before the app hydrates.
|
|
5
|
+
* We init Sentry ONLY when the public DSN is set, so with no DSN this file is a
|
|
6
|
+
* no-op and ships nothing that phones home.
|
|
7
|
+
*
|
|
8
|
+
* `process.env.NEXT_PUBLIC_*` is inlined at build time by Next — reference it
|
|
9
|
+
* literally (not via a computed key) so the value is statically replaced.
|
|
10
|
+
*
|
|
11
|
+
* Like the server side, this stays runtime + env-gated: we do NOT wrap
|
|
12
|
+
* next.config.ts with `withSentryConfig` (that would couple `next build` to a
|
|
13
|
+
* source-map upload auth token). withSentryConfig is the opt-in upgrade.
|
|
14
|
+
*/
|
|
15
|
+
import * as Sentry from '@sentry/nextjs';
|
|
16
|
+
|
|
17
|
+
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
|
18
|
+
|
|
19
|
+
if (dsn) {
|
|
20
|
+
Sentry.init({
|
|
21
|
+
dsn,
|
|
22
|
+
tracesSampleRate: process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE
|
|
23
|
+
? Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE)
|
|
24
|
+
: 0.1,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Instruments App Router navigations for tracing. Harmless when Sentry is
|
|
29
|
+
// uninitialized (no DSN).
|
|
30
|
+
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server/Edge runtime instrumentation — env-gated Sentry, INERT by default.
|
|
3
|
+
*
|
|
4
|
+
* Next.js calls `register()` once per server runtime at startup. We init Sentry
|
|
5
|
+
* ONLY when a DSN is present (a server `SENTRY_DSN`, or the public DSN shared
|
|
6
|
+
* with the browser), so with no DSN this is a clean no-op and `next build`
|
|
7
|
+
* succeeds with zero Sentry credentials.
|
|
8
|
+
*
|
|
9
|
+
* NOTE: we deliberately do NOT wrap next.config.ts with `withSentryConfig`.
|
|
10
|
+
* That adds build-time source-map upload which requires a SENTRY_AUTH_TOKEN and
|
|
11
|
+
* would couple `next build` to credentials. Source-map upload is the opt-in
|
|
12
|
+
* upgrade — add `withSentryConfig` + an auth token when you want it. This setup
|
|
13
|
+
* stays purely runtime + env-gated.
|
|
14
|
+
*/
|
|
15
|
+
import * as Sentry from '@sentry/nextjs';
|
|
16
|
+
|
|
17
|
+
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN ?? process.env.SENTRY_DSN;
|
|
18
|
+
|
|
19
|
+
const tracesSampleRate = process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE
|
|
20
|
+
? Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE)
|
|
21
|
+
: 0.1;
|
|
22
|
+
|
|
23
|
+
export async function register(): Promise<void> {
|
|
24
|
+
// No DSN → stay inert.
|
|
25
|
+
if (!dsn) return;
|
|
26
|
+
|
|
27
|
+
if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') {
|
|
28
|
+
Sentry.init({
|
|
29
|
+
dsn,
|
|
30
|
+
tracesSampleRate,
|
|
31
|
+
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Capture errors from Server Components, route handlers, and middleware.
|
|
37
|
+
// Safe no-op when Sentry was never initialized (no DSN).
|
|
38
|
+
export const onRequestError = Sentry.captureRequestError;
|
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
-
// Every field MUST have a .default() so the dev fallback
|
|
4
|
-
// can produce valid defaults when env vars are missing in
|
|
3
|
+
// Every required field MUST have a .default() so the dev fallback
|
|
4
|
+
// (envSchema.parse({})) can produce valid defaults when env vars are missing in
|
|
5
|
+
// development. Genuinely optional integrations (e.g. Sentry) use .optional()
|
|
6
|
+
// instead — unset means "disabled", and the build must pass with them absent.
|
|
5
7
|
const envSchema = z.object({
|
|
6
8
|
NEXT_PUBLIC_API_BASE_URL: z
|
|
7
9
|
.string()
|
|
8
10
|
.url('NEXT_PUBLIC_API_BASE_URL must be a valid URL')
|
|
9
11
|
.default('http://localhost:8000/api/v1'),
|
|
10
12
|
NEXT_PUBLIC_APP_NAME: z.string().default('My App'),
|
|
13
|
+
|
|
14
|
+
// --- Error Tracking (Optional, Sentry) ---
|
|
15
|
+
// Inert without a DSN: instrumentation only inits Sentry when this is set.
|
|
16
|
+
// Optional so `next build` passes with it unset. Never hardcode a DSN.
|
|
17
|
+
NEXT_PUBLIC_SENTRY_DSN: z.string().url().optional(),
|
|
18
|
+
// Fraction of transactions sampled for browser performance tracing (0..1).
|
|
19
|
+
NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE: z.coerce.number().min(0).max(1).optional(),
|
|
11
20
|
});
|
|
12
21
|
|
|
13
22
|
function validateEnv(): z.infer<typeof envSchema> {
|
|
14
23
|
const result = envSchema.safeParse({
|
|
15
24
|
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
|
|
16
25
|
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
|
|
26
|
+
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
27
|
+
NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE: process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE,
|
|
17
28
|
});
|
|
18
29
|
|
|
19
30
|
if (!result.success) {
|
|
@@ -14,9 +14,94 @@ function isTokenExpired(token: string): boolean {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Derive the origin (scheme://host[:port]) of a URL, falling back to the input
|
|
19
|
+
* when it can't be parsed. Used to turn NEXT_PUBLIC_API_BASE_URL (which may
|
|
20
|
+
* carry a path like .../api/v1) and the Sentry DSN into bare CSP source values.
|
|
21
|
+
*/
|
|
22
|
+
function toOrigin(url: string | undefined): string | null {
|
|
23
|
+
if (!url) return null;
|
|
24
|
+
try {
|
|
25
|
+
return new URL(url).origin;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const apiOrigin = toOrigin(process.env.NEXT_PUBLIC_API_BASE_URL) ?? 'http://localhost:8000';
|
|
32
|
+
// When Sentry is configured, its browser SDK POSTs envelopes to the DSN origin;
|
|
33
|
+
// it must be allowed in connect-src or every error report is blocked by CSP.
|
|
34
|
+
const sentryOrigin = toOrigin(process.env.NEXT_PUBLIC_SENTRY_DSN);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the per-request Content-Security-Policy. A fresh nonce is minted for
|
|
38
|
+
* every request and threaded onto Next's own scripts (see middleware below), so
|
|
39
|
+
* 'strict-dynamic' can trust them while refusing any attacker-injected inline
|
|
40
|
+
* script that lacks the nonce.
|
|
41
|
+
*
|
|
42
|
+
* DEV relaxes script-src with 'unsafe-eval' (Next's HMR/react-refresh evals) and
|
|
43
|
+
* style-src with 'unsafe-inline' (the dev overlay injects inline styles). PROD
|
|
44
|
+
* is strict. Style-src keeps 'unsafe-inline' in PROD too: Tailwind v4 and some
|
|
45
|
+
* libraries emit inline <style>/style="" that a nonce cannot cover and that a
|
|
46
|
+
* strict style-src would REFUSE, breaking the page. Inline styles cannot
|
|
47
|
+
* exfiltrate data the way inline scripts can, so this is an accepted tradeoff —
|
|
48
|
+
* the high-value half (the strict, nonce-gated script-src) stays intact.
|
|
49
|
+
*/
|
|
50
|
+
function buildCsp(nonce: string, isProd: boolean): string {
|
|
51
|
+
const connectSrc = ["'self'", apiOrigin, sentryOrigin].filter(Boolean).join(' ');
|
|
52
|
+
|
|
53
|
+
const scriptSrc = isProd
|
|
54
|
+
? `'self' 'nonce-${nonce}' 'strict-dynamic' https:`
|
|
55
|
+
: `'self' 'nonce-${nonce}' 'strict-dynamic' 'unsafe-eval' https:`;
|
|
56
|
+
|
|
57
|
+
// 'unsafe-inline' in style-src in BOTH modes (see note above).
|
|
58
|
+
const styleSrc = `'self' 'unsafe-inline'`;
|
|
59
|
+
|
|
60
|
+
return [
|
|
61
|
+
`default-src 'self'`,
|
|
62
|
+
`script-src ${scriptSrc}`,
|
|
63
|
+
`style-src ${styleSrc}`,
|
|
64
|
+
`img-src 'self' blob: data: ${apiOrigin}`,
|
|
65
|
+
`font-src 'self'`,
|
|
66
|
+
`connect-src ${connectSrc}`,
|
|
67
|
+
`object-src 'none'`,
|
|
68
|
+
`base-uri 'none'`,
|
|
69
|
+
`form-action 'self'`,
|
|
70
|
+
`frame-ancestors 'none'`,
|
|
71
|
+
`upgrade-insecure-requests`,
|
|
72
|
+
].join('; ');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Attach the static security headers shared by every response. HSTS is
|
|
77
|
+
* deliberately NOT set here — it is managed at the Cloudflare edge.
|
|
78
|
+
*/
|
|
79
|
+
function applySecurityHeaders(response: NextResponse): NextResponse {
|
|
80
|
+
response.headers.set('X-Content-Type-Options', 'nosniff');
|
|
81
|
+
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
82
|
+
// Redundant with frame-ancestors 'none' but harmless for legacy browsers.
|
|
83
|
+
response.headers.set('X-Frame-Options', 'DENY');
|
|
84
|
+
return response;
|
|
85
|
+
}
|
|
86
|
+
|
|
17
87
|
export function middleware(request: NextRequest): NextResponse {
|
|
18
88
|
const { pathname } = request.nextUrl;
|
|
19
89
|
|
|
90
|
+
const isProd = process.env.NODE_ENV === 'production';
|
|
91
|
+
|
|
92
|
+
// --- Per-request nonce + CSP (canonical Next.js pattern) ---
|
|
93
|
+
// base64-encoded random UUID. Minted fresh per request so 'strict-dynamic'
|
|
94
|
+
// only trusts scripts we (and Next) emit this request.
|
|
95
|
+
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
|
|
96
|
+
const cspHeader = buildCsp(nonce, isProd);
|
|
97
|
+
|
|
98
|
+
// Propagate the nonce + CSP on the REQUEST headers. Next reads the CSP from
|
|
99
|
+
// the request to extract the nonce and stamps it onto its own <script> tags;
|
|
100
|
+
// without this, strict-dynamic would refuse Next's scripts and break the page.
|
|
101
|
+
const requestHeaders = new Headers(request.headers);
|
|
102
|
+
requestHeaders.set('x-nonce', nonce);
|
|
103
|
+
requestHeaders.set('content-security-policy', cspHeader);
|
|
104
|
+
|
|
20
105
|
// NOTE: This middleware only checks COOKIE PRESENCE (and a best-effort,
|
|
21
106
|
// unverified exp claim) for UX-level routing — redirecting users who are
|
|
22
107
|
// obviously logged out away from protected pages. It does NOT verify the
|
|
@@ -29,36 +114,38 @@ export function middleware(request: NextRequest): NextResponse {
|
|
|
29
114
|
const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path));
|
|
30
115
|
|
|
31
116
|
if (isProtectedPath) {
|
|
32
|
-
// No access_token AND no auth_session — truly unauthenticated
|
|
117
|
+
// No access_token AND no auth_session — truly unauthenticated.
|
|
33
118
|
if (!accessToken && !authSession) {
|
|
34
119
|
const loginUrl = new URL('/login', request.url);
|
|
35
120
|
loginUrl.searchParams.set('from', pathname);
|
|
36
|
-
|
|
121
|
+
const redirect = NextResponse.redirect(loginUrl);
|
|
122
|
+
redirect.headers.set('content-security-policy', cspHeader);
|
|
123
|
+
return applySecurityHeaders(redirect);
|
|
37
124
|
}
|
|
125
|
+
}
|
|
38
126
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
127
|
+
// Single .next() response carrying the propagated request headers, the CSP
|
|
128
|
+
// (also on the response, for the browser), and the static security headers.
|
|
129
|
+
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
|
130
|
+
response.headers.set('content-security-policy', cspHeader);
|
|
131
|
+
applySecurityHeaders(response);
|
|
44
132
|
|
|
45
|
-
|
|
133
|
+
if (isProtectedPath) {
|
|
134
|
+
// No access_token BUT auth_session exists — session is alive, let through
|
|
135
|
+
// so client-side can do getMe() → 401 → refresh → retry.
|
|
136
|
+
// (response already passes through; nothing extra needed here.)
|
|
137
|
+
|
|
138
|
+
// Expired access_token — delete stale cookie, let through for client refresh.
|
|
46
139
|
if (accessToken && isTokenExpired(accessToken)) {
|
|
47
|
-
const response = NextResponse.next();
|
|
48
140
|
response.cookies.delete('access_token');
|
|
49
|
-
return response;
|
|
50
141
|
}
|
|
51
|
-
|
|
52
|
-
return NextResponse.next();
|
|
53
142
|
}
|
|
54
143
|
|
|
55
|
-
return
|
|
144
|
+
return response;
|
|
56
145
|
}
|
|
57
146
|
|
|
58
147
|
export const config = {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
'/admin/:path*',
|
|
63
|
-
],
|
|
148
|
+
// Apply security headers site-wide, but skip Next's static assets and the
|
|
149
|
+
// favicon (no CSP needed on immutable static files / images).
|
|
150
|
+
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
|
64
151
|
};
|
|
@@ -129,6 +129,18 @@ REQUIRE_USER_VERIFICATION=true
|
|
|
129
129
|
# Maximum file upload size in MB (default: 10)
|
|
130
130
|
MAX_FILE_SIZE_MB=10
|
|
131
131
|
|
|
132
|
+
# Two-tier upload storage. BOTH optional — leave unset to use the defaults
|
|
133
|
+
# (<cwd>/uploads/public and <cwd>/uploads/private).
|
|
134
|
+
# PUBLIC tier: served as static files at /uploads/ — viewable by ANYONE,
|
|
135
|
+
# including unauthenticated users (e.g. avatars). @fastify/static
|
|
136
|
+
# is scoped to ONLY this dir so it can never serve a private file.
|
|
137
|
+
# PRIVATE tier: lives OUTSIDE the static root; served ONLY through the
|
|
138
|
+
# auth-gated, owner-scoped route GET /api/v1/files/:filename.
|
|
139
|
+
# Both must live UNDER the Coolify uploads volume (/app/uploads/...) so files
|
|
140
|
+
# survive redeploys — see COOLIFY PERSISTENT STORAGE below.
|
|
141
|
+
# UPLOAD_PUBLIC_DIR=/app/uploads/public
|
|
142
|
+
# UPLOAD_PRIVATE_DIR=/app/uploads/private
|
|
143
|
+
|
|
132
144
|
# COOLIFY PERSISTENT STORAGE (required for uploads to survive redeployments):
|
|
133
145
|
# Go to your service in Coolify → Storages → Add Volume Mount:
|
|
134
146
|
# Name: uploads (or <project-name>-uploads)
|
|
@@ -243,6 +255,15 @@ LOG_LEVEL=info
|
|
|
243
255
|
|
|
244
256
|
# Sentry DSN for error tracking and monitoring
|
|
245
257
|
# Get your DSN from: https://sentry.io/settings/projects/
|
|
246
|
-
# Leave empty to disable Sentry
|
|
258
|
+
# Leave empty to disable Sentry — with no DSN, init is a no-op (fully inert).
|
|
259
|
+
# COOLIFY: Runtime only. Do NOT check "Available at Buildtime".
|
|
247
260
|
# Example: SENTRY_DSN="https://examplePublicKey@o0.ingest.sentry.io/0"
|
|
248
261
|
# SENTRY_DSN=""
|
|
262
|
+
|
|
263
|
+
# Fraction of transactions sampled for performance tracing (0.0–1.0, default 0.1).
|
|
264
|
+
# Only takes effect when SENTRY_DSN is set.
|
|
265
|
+
# SENTRY_TRACES_SAMPLE_RATE=0.1
|
|
266
|
+
|
|
267
|
+
# Environment tag attached to Sentry events (e.g. production, staging).
|
|
268
|
+
# Defaults to NODE_ENV when unset.
|
|
269
|
+
# SENTRY_ENVIRONMENT=""
|
|
@@ -122,6 +122,15 @@ REQUIRE_USER_VERIFICATION=true
|
|
|
122
122
|
# Maximum file upload size in MB
|
|
123
123
|
MAX_FILE_SIZE_MB=10
|
|
124
124
|
|
|
125
|
+
# Two-tier upload storage. BOTH optional — defaults are <cwd>/uploads/public and
|
|
126
|
+
# <cwd>/uploads/private. Keep them UNDER /app/uploads (the Coolify volume below)
|
|
127
|
+
# so files survive redeploys.
|
|
128
|
+
# PUBLIC tier: static files at /uploads/ — viewable by ANYONE (e.g. avatars).
|
|
129
|
+
# PRIVATE tier: OUTSIDE the static root; served ONLY via the auth-gated,
|
|
130
|
+
# owner-scoped route GET /api/v1/files/:filename.
|
|
131
|
+
# UPLOAD_PUBLIC_DIR=/app/uploads/public
|
|
132
|
+
# UPLOAD_PRIVATE_DIR=/app/uploads/private
|
|
133
|
+
|
|
125
134
|
# COOLIFY PERSISTENT STORAGE (required for uploads to survive redeployments):
|
|
126
135
|
# Go to your service in Coolify → Storages → Add Volume Mount:
|
|
127
136
|
# Name: uploads (or <project-name>-uploads)
|
|
@@ -199,10 +208,16 @@ LOG_LEVEL=info
|
|
|
199
208
|
# ERROR TRACKING
|
|
200
209
|
# ===================================================================
|
|
201
210
|
|
|
202
|
-
# Sentry for production error monitoring
|
|
211
|
+
# Sentry for production error monitoring (leave unset to disable — fully inert)
|
|
203
212
|
# Get your DSN from: https://sentry.io/settings/projects/
|
|
204
213
|
SENTRY_DSN="https://YOUR_PUBLIC_KEY@o0.ingest.sentry.io/YOUR_PROJECT_ID"
|
|
205
214
|
|
|
215
|
+
# Fraction of transactions sampled for performance tracing (0.0–1.0, default 0.1).
|
|
216
|
+
SENTRY_TRACES_SAMPLE_RATE=0.1
|
|
217
|
+
|
|
218
|
+
# Environment tag on Sentry events (defaults to NODE_ENV when unset).
|
|
219
|
+
SENTRY_ENVIRONMENT=production
|
|
220
|
+
|
|
206
221
|
# ===================================================================
|
|
207
222
|
# PRODUCTION CHECKLIST
|
|
208
223
|
# ===================================================================
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
# ===================================================================
|
|
7
7
|
# Stage 1: Dependencies (cached layer)
|
|
8
8
|
# ===================================================================
|
|
9
|
-
FROM node:20-alpine AS dependencies
|
|
9
|
+
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS dependencies
|
|
10
10
|
|
|
11
11
|
# Install production dependencies only
|
|
12
12
|
WORKDIR /app
|
|
@@ -38,7 +38,7 @@ RUN npm install --no-save --no-audit --no-fund \
|
|
|
38
38
|
# ===================================================================
|
|
39
39
|
# Stage 2: Build (compile TypeScript)
|
|
40
40
|
# ===================================================================
|
|
41
|
-
FROM node:20-alpine AS builder
|
|
41
|
+
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS builder
|
|
42
42
|
|
|
43
43
|
# Override NODE_ENV to ensure devDependencies are installed.
|
|
44
44
|
# Coolify may inject NODE_ENV=production as a build arg, which causes npm ci
|
|
@@ -70,7 +70,7 @@ RUN npm run build
|
|
|
70
70
|
# ===================================================================
|
|
71
71
|
# Stage 3: Production Runtime
|
|
72
72
|
# ===================================================================
|
|
73
|
-
FROM node:20-alpine AS production
|
|
73
|
+
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS production
|
|
74
74
|
|
|
75
75
|
# Install dumb-init for proper signal handling
|
|
76
76
|
RUN apk add --no-cache dumb-init
|
|
@@ -96,8 +96,10 @@ COPY --from=builder --chown=nodejs:nodejs /app/package.json ./package.json
|
|
|
96
96
|
# Copy Prisma files (needed for migrations)
|
|
97
97
|
COPY --from=builder --chown=nodejs:nodejs /app/prisma ./prisma
|
|
98
98
|
|
|
99
|
-
# Create
|
|
100
|
-
|
|
99
|
+
# Create BOTH upload tiers with correct ownership (must be before USER nodejs).
|
|
100
|
+
# public/ is served as static files at /uploads/; private/ is served ONLY via
|
|
101
|
+
# the auth-gated route GET /api/v1/files/:filename. See file-storage.service.ts.
|
|
102
|
+
RUN mkdir -p /app/uploads/public /app/uploads/private && chown -R nodejs:nodejs /app/uploads
|
|
101
103
|
|
|
102
104
|
# Switch to non-root user
|
|
103
105
|
USER nodejs
|