@better-forge/runtime 0.1.0 → 0.1.1
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/LICENSE +21 -21
- package/README.md +9 -9
- package/package.json +65 -65
- package/src/appLock.ts +63 -63
- package/src/createBetterApp.ts +236 -236
- package/src/dataPaths.ts +48 -48
- package/src/errorHandler.ts +15 -15
- package/src/health.ts +109 -109
- package/src/index.ts +14 -14
- package/src/processLiveness.ts +31 -31
- package/src/proxy.ts +20 -20
- package/src/publicUrl.ts +26 -26
- package/src/rateLimitKey.ts +20 -20
- package/src/redactUrl.ts +49 -49
- package/src/shutdown.ts +62 -62
- package/src/sse.ts +154 -154
- package/src/updateCheck.ts +107 -107
- package/src/version.ts +7 -7
package/src/createBetterApp.ts
CHANGED
|
@@ -1,236 +1,236 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
|
-
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify'
|
|
3
|
-
import cors from '@fastify/cors'
|
|
4
|
-
import cookie from '@fastify/cookie'
|
|
5
|
-
import jwt from '@fastify/jwt'
|
|
6
|
-
import multipart from '@fastify/multipart'
|
|
7
|
-
import rateLimit from '@fastify/rate-limit'
|
|
8
|
-
import { rateLimitKey } from './rateLimitKey.js'
|
|
9
|
-
import staticFiles from '@fastify/static'
|
|
10
|
-
import { errorHandler } from './errorHandler.js'
|
|
11
|
-
import { redactUrl } from './redactUrl.js'
|
|
12
|
-
import { resolveTrustProxy } from './proxy.js'
|
|
13
|
-
import { createRuntimeShutdown } from './shutdown.js'
|
|
14
|
-
|
|
15
|
-
const DEFAULT_JWT_SECRET = 'dev-secret-change-me'
|
|
16
|
-
const EXAMPLE_SECRETS = new Set([
|
|
17
|
-
'change-me-to-a-long-random-string',
|
|
18
|
-
'change-me-to-a-64-char-hex-string',
|
|
19
|
-
])
|
|
20
|
-
|
|
21
|
-
/** How long a signed-in session token lives. */
|
|
22
|
-
export const JWT_EXPIRES_IN = '12h'
|
|
23
|
-
|
|
24
|
-
/** The secret sessions are signed with: from `JWT_SECRET`, which production insists on being a real one. */
|
|
25
|
-
export function resolveJwtSecret(env: NodeJS.ProcessEnv = process.env): string {
|
|
26
|
-
const secret = env['JWT_SECRET']
|
|
27
|
-
if (env['NODE_ENV'] !== 'production') {
|
|
28
|
-
if (!secret) console.warn('JWT_SECRET not set - using insecure default (development only)')
|
|
29
|
-
return secret ?? DEFAULT_JWT_SECRET
|
|
30
|
-
}
|
|
31
|
-
if (!secret || secret.length < 32 || EXAMPLE_SECRETS.has(secret)) {
|
|
32
|
-
throw new Error('JWT_SECRET must be set to a non-default value of at least 32 characters in production')
|
|
33
|
-
}
|
|
34
|
-
return secret
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface BetterAppOptions {
|
|
38
|
-
/** Request URLs are logged with the value of these query parameters replaced (capability tokens in links). */
|
|
39
|
-
redactQueryParams?: readonly string[]
|
|
40
|
-
/** Request paths whose next segment is a capability token, for example `/links/` (the token is not logged). */
|
|
41
|
-
redactPathTokenPrefixes?: readonly string[]
|
|
42
|
-
/** Checks run before anything is created (for example that the encryption key is usable); throw to refuse to start. */
|
|
43
|
-
validate?: () => void
|
|
44
|
-
/** Where uploaded files are kept and served from (`/uploads/`). */
|
|
45
|
-
uploads: { dir: string; maxFileBytes: number }
|
|
46
|
-
/** Whether a response to this request may be shown in a frame of our own origin (anything else is refused). */
|
|
47
|
-
allowSameOriginFraming?: (request: { path: string; query: URLSearchParams }) => boolean
|
|
48
|
-
/** The database is opened only once the application has been set up. */
|
|
49
|
-
database: {
|
|
50
|
-
isReady: () => boolean
|
|
51
|
-
open: () => void
|
|
52
|
-
migrate: () => void
|
|
53
|
-
close: () => void
|
|
54
|
-
}
|
|
55
|
-
/** Registers every route of the application. */
|
|
56
|
-
routes: (app: FastifyInstance) => Promise<void>
|
|
57
|
-
/** Registers the built front-ends; called in production only, after the routes. */
|
|
58
|
-
frontends?: (app: FastifyInstance) => Promise<void>
|
|
59
|
-
/** Takes the lock that keeps a second instance away from the data directory; returns the release. */
|
|
60
|
-
acquireLock: () => () => void
|
|
61
|
-
/** Work that runs only while the application is set up. */
|
|
62
|
-
backgroundServices?: {
|
|
63
|
-
start: () => void
|
|
64
|
-
stop: () => void
|
|
65
|
-
/** Resolves once the work `stop` left running has finished; the shutdown waits for it until its deadline. */
|
|
66
|
-
drain?: () => Promise<void>
|
|
67
|
-
}
|
|
68
|
-
/** Called when the process is asked to stop, before the server drains (close event streams, say). */
|
|
69
|
-
stopIntake?: () => void
|
|
70
|
-
/** How long a stop waits for the server and the background work to drain before it exits anyway. Default 5 seconds. */
|
|
71
|
-
shutdownDeadlineMs?: number
|
|
72
|
-
/** Name used in the line logged when the server is up. */
|
|
73
|
-
logName?: string
|
|
74
|
-
port?: number
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Builds the HTTP application the Better products share: logging that hides credentials, proxy trust, CORS and a
|
|
79
|
-
* cross-site guard, cookies, JWT sessions, rate limiting, uploads, security headers, the database once set up, and the
|
|
80
|
-
* application's routes. `run` then takes the lock, listens, and wires the shutdown that closes everything in order.
|
|
81
|
-
*/
|
|
82
|
-
export async function createBetterApp(options: BetterAppOptions) {
|
|
83
|
-
const redaction = { queryParams: options.redactQueryParams, pathTokenPrefixes: options.redactPathTokenPrefixes }
|
|
84
|
-
|
|
85
|
-
const app = Fastify({
|
|
86
|
-
logger: {
|
|
87
|
-
level: 'info',
|
|
88
|
-
redact: {
|
|
89
|
-
paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
|
|
90
|
-
censor: '[redacted]',
|
|
91
|
-
},
|
|
92
|
-
serializers: {
|
|
93
|
-
req: (req) => ({
|
|
94
|
-
method: req.method,
|
|
95
|
-
url: redactUrl(req.url, redaction),
|
|
96
|
-
host: req.host,
|
|
97
|
-
remoteAddress: req.ip,
|
|
98
|
-
...(req.socket?.remotePort !== undefined ? { remotePort: req.socket.remotePort } : {}),
|
|
99
|
-
}),
|
|
100
|
-
},
|
|
101
|
-
},
|
|
102
|
-
trustProxy: resolveTrustProxy(),
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
app.setErrorHandler(errorHandler)
|
|
106
|
-
|
|
107
|
-
// CORS
|
|
108
|
-
const allowedOrigins = process.env['ALLOWED_ORIGINS']?.split(',').map((o) => o.trim())
|
|
109
|
-
await app.register(cors, {
|
|
110
|
-
origin: allowedOrigins
|
|
111
|
-
?? (process.env['NODE_ENV'] === 'production' ? false : true),
|
|
112
|
-
credentials: true,
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
await app.register(cookie)
|
|
116
|
-
|
|
117
|
-
// Reject unsafe cross-site browser requests before they reach auth or setup routes.
|
|
118
|
-
app.addHook('onRequest', async (req, reply) => {
|
|
119
|
-
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return
|
|
120
|
-
if (process.env['NODE_ENV'] !== 'production') return
|
|
121
|
-
if (req.headers['sec-fetch-site'] === 'cross-site') {
|
|
122
|
-
return reply.code(403).send({ error: 'Cross-site request rejected' })
|
|
123
|
-
}
|
|
124
|
-
const origin = req.headers.origin
|
|
125
|
-
if (!origin) return
|
|
126
|
-
const ownOrigin = `${req.protocol}://${req.headers.host}`
|
|
127
|
-
if (origin !== ownOrigin && !allowedOrigins?.includes(origin)) {
|
|
128
|
-
return reply.code(403).send({ error: 'Origin not allowed' })
|
|
129
|
-
}
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
// JWT
|
|
133
|
-
const jwtSecret = resolveJwtSecret()
|
|
134
|
-
options.validate?.()
|
|
135
|
-
await app.register(jwt, {
|
|
136
|
-
secret: jwtSecret,
|
|
137
|
-
sign: { expiresIn: JWT_EXPIRES_IN },
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
// Rate limiting (applied per-route where needed); IPv6 clients are counted per /64
|
|
141
|
-
await app.register(rateLimit, { global: false, keyGenerator: (req) => rateLimitKey(req.ip) })
|
|
142
|
-
|
|
143
|
-
// Multipart (file uploads)
|
|
144
|
-
await app.register(multipart, {
|
|
145
|
-
limits: { fileSize: options.uploads.maxFileBytes },
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
// Serve uploaded files
|
|
149
|
-
if (!fs.existsSync(options.uploads.dir)) {
|
|
150
|
-
fs.mkdirSync(options.uploads.dir, { recursive: true })
|
|
151
|
-
}
|
|
152
|
-
await app.register(staticFiles, {
|
|
153
|
-
root: options.uploads.dir,
|
|
154
|
-
prefix: '/uploads/',
|
|
155
|
-
decorateReply: false,
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
// Security headers
|
|
159
|
-
app.addHook('onSend', (req: FastifyRequest, reply, _payload, done) => {
|
|
160
|
-
const [requestPath = '', query = ''] = req.url.split('?', 2)
|
|
161
|
-
const framed = options.allowSameOriginFraming?.({ path: requestPath, query: new URLSearchParams(query) }) ?? false
|
|
162
|
-
reply.header('X-Content-Type-Options', 'nosniff')
|
|
163
|
-
reply.header('X-Frame-Options', framed ? 'SAMEORIGIN' : 'DENY')
|
|
164
|
-
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
165
|
-
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()')
|
|
166
|
-
reply.header('Cross-Origin-Opener-Policy', 'same-origin')
|
|
167
|
-
reply.header('Content-Security-Policy', [
|
|
168
|
-
"default-src 'self'",
|
|
169
|
-
"base-uri 'self'",
|
|
170
|
-
"object-src 'none'",
|
|
171
|
-
framed ? "frame-ancestors 'self'" : "frame-ancestors 'none'",
|
|
172
|
-
"form-action 'self'",
|
|
173
|
-
"script-src 'self'",
|
|
174
|
-
"style-src 'self' 'unsafe-inline'",
|
|
175
|
-
"font-src 'self'",
|
|
176
|
-
"img-src 'self' data: blob:",
|
|
177
|
-
"connect-src 'self'",
|
|
178
|
-
].join('; '))
|
|
179
|
-
if (process.env['NODE_ENV'] === 'production') {
|
|
180
|
-
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
|
|
181
|
-
}
|
|
182
|
-
done()
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
// Initialize DB only when setup has been completed
|
|
186
|
-
if (options.database.isReady()) {
|
|
187
|
-
options.database.open()
|
|
188
|
-
options.database.migrate()
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
await options.routes(app)
|
|
192
|
-
|
|
193
|
-
// Serve built frontend apps in production
|
|
194
|
-
if (process.env['NODE_ENV'] === 'production') await options.frontends?.(app)
|
|
195
|
-
|
|
196
|
-
/** Takes the lock, listens, and makes the process stop cleanly on SIGTERM and SIGINT. */
|
|
197
|
-
async function run(): Promise<void> {
|
|
198
|
-
const port = options.port ?? Number(process.env['PORT'] ?? 3000)
|
|
199
|
-
const releaseLock = options.acquireLock()
|
|
200
|
-
const runtime = createRuntimeShutdown({
|
|
201
|
-
stopIntake: () => {
|
|
202
|
-
options.stopIntake?.()
|
|
203
|
-
options.backgroundServices?.stop()
|
|
204
|
-
},
|
|
205
|
-
closeServer: () => app.close(),
|
|
206
|
-
...(options.backgroundServices?.drain ? { drain: options.backgroundServices.drain } : {}),
|
|
207
|
-
...(options.shutdownDeadlineMs !== undefined ? { forceExitMs: options.shutdownDeadlineMs } : {}),
|
|
208
|
-
releaseResources: () => {
|
|
209
|
-
options.database.close()
|
|
210
|
-
releaseLock()
|
|
211
|
-
},
|
|
212
|
-
exit: (code) => process.exit(code),
|
|
213
|
-
})
|
|
214
|
-
// onClose runs after the HTTP server has drained, so the DB is no longer in use.
|
|
215
|
-
app.addHook('onClose', async () => {
|
|
216
|
-
runtime.cleanup()
|
|
217
|
-
})
|
|
218
|
-
try {
|
|
219
|
-
await app.listen({ port, host: '0.0.0.0' })
|
|
220
|
-
} catch (error) {
|
|
221
|
-
runtime.cleanup()
|
|
222
|
-
throw error
|
|
223
|
-
}
|
|
224
|
-
console.log(`✓ ${options.logName ?? 'API'} running on http://localhost:${port}`)
|
|
225
|
-
|
|
226
|
-
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
|
227
|
-
process.once(signal, runtime.shutdown)
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (options.database.isReady()) options.backgroundServices?.start()
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
return { app, run }
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
export type BetterApp = Awaited<ReturnType<typeof createBetterApp>>
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify'
|
|
3
|
+
import cors from '@fastify/cors'
|
|
4
|
+
import cookie from '@fastify/cookie'
|
|
5
|
+
import jwt from '@fastify/jwt'
|
|
6
|
+
import multipart from '@fastify/multipart'
|
|
7
|
+
import rateLimit from '@fastify/rate-limit'
|
|
8
|
+
import { rateLimitKey } from './rateLimitKey.js'
|
|
9
|
+
import staticFiles from '@fastify/static'
|
|
10
|
+
import { errorHandler } from './errorHandler.js'
|
|
11
|
+
import { redactUrl } from './redactUrl.js'
|
|
12
|
+
import { resolveTrustProxy } from './proxy.js'
|
|
13
|
+
import { createRuntimeShutdown } from './shutdown.js'
|
|
14
|
+
|
|
15
|
+
const DEFAULT_JWT_SECRET = 'dev-secret-change-me'
|
|
16
|
+
const EXAMPLE_SECRETS = new Set([
|
|
17
|
+
'change-me-to-a-long-random-string',
|
|
18
|
+
'change-me-to-a-64-char-hex-string',
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
/** How long a signed-in session token lives. */
|
|
22
|
+
export const JWT_EXPIRES_IN = '12h'
|
|
23
|
+
|
|
24
|
+
/** The secret sessions are signed with: from `JWT_SECRET`, which production insists on being a real one. */
|
|
25
|
+
export function resolveJwtSecret(env: NodeJS.ProcessEnv = process.env): string {
|
|
26
|
+
const secret = env['JWT_SECRET']
|
|
27
|
+
if (env['NODE_ENV'] !== 'production') {
|
|
28
|
+
if (!secret) console.warn('JWT_SECRET not set - using insecure default (development only)')
|
|
29
|
+
return secret ?? DEFAULT_JWT_SECRET
|
|
30
|
+
}
|
|
31
|
+
if (!secret || secret.length < 32 || EXAMPLE_SECRETS.has(secret)) {
|
|
32
|
+
throw new Error('JWT_SECRET must be set to a non-default value of at least 32 characters in production')
|
|
33
|
+
}
|
|
34
|
+
return secret
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BetterAppOptions {
|
|
38
|
+
/** Request URLs are logged with the value of these query parameters replaced (capability tokens in links). */
|
|
39
|
+
redactQueryParams?: readonly string[]
|
|
40
|
+
/** Request paths whose next segment is a capability token, for example `/links/` (the token is not logged). */
|
|
41
|
+
redactPathTokenPrefixes?: readonly string[]
|
|
42
|
+
/** Checks run before anything is created (for example that the encryption key is usable); throw to refuse to start. */
|
|
43
|
+
validate?: () => void
|
|
44
|
+
/** Where uploaded files are kept and served from (`/uploads/`). */
|
|
45
|
+
uploads: { dir: string; maxFileBytes: number }
|
|
46
|
+
/** Whether a response to this request may be shown in a frame of our own origin (anything else is refused). */
|
|
47
|
+
allowSameOriginFraming?: (request: { path: string; query: URLSearchParams }) => boolean
|
|
48
|
+
/** The database is opened only once the application has been set up. */
|
|
49
|
+
database: {
|
|
50
|
+
isReady: () => boolean
|
|
51
|
+
open: () => void
|
|
52
|
+
migrate: () => void
|
|
53
|
+
close: () => void
|
|
54
|
+
}
|
|
55
|
+
/** Registers every route of the application. */
|
|
56
|
+
routes: (app: FastifyInstance) => Promise<void>
|
|
57
|
+
/** Registers the built front-ends; called in production only, after the routes. */
|
|
58
|
+
frontends?: (app: FastifyInstance) => Promise<void>
|
|
59
|
+
/** Takes the lock that keeps a second instance away from the data directory; returns the release. */
|
|
60
|
+
acquireLock: () => () => void
|
|
61
|
+
/** Work that runs only while the application is set up. */
|
|
62
|
+
backgroundServices?: {
|
|
63
|
+
start: () => void
|
|
64
|
+
stop: () => void
|
|
65
|
+
/** Resolves once the work `stop` left running has finished; the shutdown waits for it until its deadline. */
|
|
66
|
+
drain?: () => Promise<void>
|
|
67
|
+
}
|
|
68
|
+
/** Called when the process is asked to stop, before the server drains (close event streams, say). */
|
|
69
|
+
stopIntake?: () => void
|
|
70
|
+
/** How long a stop waits for the server and the background work to drain before it exits anyway. Default 5 seconds. */
|
|
71
|
+
shutdownDeadlineMs?: number
|
|
72
|
+
/** Name used in the line logged when the server is up. */
|
|
73
|
+
logName?: string
|
|
74
|
+
port?: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Builds the HTTP application the Better products share: logging that hides credentials, proxy trust, CORS and a
|
|
79
|
+
* cross-site guard, cookies, JWT sessions, rate limiting, uploads, security headers, the database once set up, and the
|
|
80
|
+
* application's routes. `run` then takes the lock, listens, and wires the shutdown that closes everything in order.
|
|
81
|
+
*/
|
|
82
|
+
export async function createBetterApp(options: BetterAppOptions) {
|
|
83
|
+
const redaction = { queryParams: options.redactQueryParams, pathTokenPrefixes: options.redactPathTokenPrefixes }
|
|
84
|
+
|
|
85
|
+
const app = Fastify({
|
|
86
|
+
logger: {
|
|
87
|
+
level: 'info',
|
|
88
|
+
redact: {
|
|
89
|
+
paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
|
|
90
|
+
censor: '[redacted]',
|
|
91
|
+
},
|
|
92
|
+
serializers: {
|
|
93
|
+
req: (req) => ({
|
|
94
|
+
method: req.method,
|
|
95
|
+
url: redactUrl(req.url, redaction),
|
|
96
|
+
host: req.host,
|
|
97
|
+
remoteAddress: req.ip,
|
|
98
|
+
...(req.socket?.remotePort !== undefined ? { remotePort: req.socket.remotePort } : {}),
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
trustProxy: resolveTrustProxy(),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
app.setErrorHandler(errorHandler)
|
|
106
|
+
|
|
107
|
+
// CORS
|
|
108
|
+
const allowedOrigins = process.env['ALLOWED_ORIGINS']?.split(',').map((o) => o.trim())
|
|
109
|
+
await app.register(cors, {
|
|
110
|
+
origin: allowedOrigins
|
|
111
|
+
?? (process.env['NODE_ENV'] === 'production' ? false : true),
|
|
112
|
+
credentials: true,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
await app.register(cookie)
|
|
116
|
+
|
|
117
|
+
// Reject unsafe cross-site browser requests before they reach auth or setup routes.
|
|
118
|
+
app.addHook('onRequest', async (req, reply) => {
|
|
119
|
+
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return
|
|
120
|
+
if (process.env['NODE_ENV'] !== 'production') return
|
|
121
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') {
|
|
122
|
+
return reply.code(403).send({ error: 'Cross-site request rejected' })
|
|
123
|
+
}
|
|
124
|
+
const origin = req.headers.origin
|
|
125
|
+
if (!origin) return
|
|
126
|
+
const ownOrigin = `${req.protocol}://${req.headers.host}`
|
|
127
|
+
if (origin !== ownOrigin && !allowedOrigins?.includes(origin)) {
|
|
128
|
+
return reply.code(403).send({ error: 'Origin not allowed' })
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// JWT
|
|
133
|
+
const jwtSecret = resolveJwtSecret()
|
|
134
|
+
options.validate?.()
|
|
135
|
+
await app.register(jwt, {
|
|
136
|
+
secret: jwtSecret,
|
|
137
|
+
sign: { expiresIn: JWT_EXPIRES_IN },
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// Rate limiting (applied per-route where needed); IPv6 clients are counted per /64
|
|
141
|
+
await app.register(rateLimit, { global: false, keyGenerator: (req) => rateLimitKey(req.ip) })
|
|
142
|
+
|
|
143
|
+
// Multipart (file uploads)
|
|
144
|
+
await app.register(multipart, {
|
|
145
|
+
limits: { fileSize: options.uploads.maxFileBytes },
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Serve uploaded files
|
|
149
|
+
if (!fs.existsSync(options.uploads.dir)) {
|
|
150
|
+
fs.mkdirSync(options.uploads.dir, { recursive: true })
|
|
151
|
+
}
|
|
152
|
+
await app.register(staticFiles, {
|
|
153
|
+
root: options.uploads.dir,
|
|
154
|
+
prefix: '/uploads/',
|
|
155
|
+
decorateReply: false,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Security headers
|
|
159
|
+
app.addHook('onSend', (req: FastifyRequest, reply, _payload, done) => {
|
|
160
|
+
const [requestPath = '', query = ''] = req.url.split('?', 2)
|
|
161
|
+
const framed = options.allowSameOriginFraming?.({ path: requestPath, query: new URLSearchParams(query) }) ?? false
|
|
162
|
+
reply.header('X-Content-Type-Options', 'nosniff')
|
|
163
|
+
reply.header('X-Frame-Options', framed ? 'SAMEORIGIN' : 'DENY')
|
|
164
|
+
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
165
|
+
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()')
|
|
166
|
+
reply.header('Cross-Origin-Opener-Policy', 'same-origin')
|
|
167
|
+
reply.header('Content-Security-Policy', [
|
|
168
|
+
"default-src 'self'",
|
|
169
|
+
"base-uri 'self'",
|
|
170
|
+
"object-src 'none'",
|
|
171
|
+
framed ? "frame-ancestors 'self'" : "frame-ancestors 'none'",
|
|
172
|
+
"form-action 'self'",
|
|
173
|
+
"script-src 'self'",
|
|
174
|
+
"style-src 'self' 'unsafe-inline'",
|
|
175
|
+
"font-src 'self'",
|
|
176
|
+
"img-src 'self' data: blob:",
|
|
177
|
+
"connect-src 'self'",
|
|
178
|
+
].join('; '))
|
|
179
|
+
if (process.env['NODE_ENV'] === 'production') {
|
|
180
|
+
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
|
|
181
|
+
}
|
|
182
|
+
done()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// Initialize DB only when setup has been completed
|
|
186
|
+
if (options.database.isReady()) {
|
|
187
|
+
options.database.open()
|
|
188
|
+
options.database.migrate()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await options.routes(app)
|
|
192
|
+
|
|
193
|
+
// Serve built frontend apps in production
|
|
194
|
+
if (process.env['NODE_ENV'] === 'production') await options.frontends?.(app)
|
|
195
|
+
|
|
196
|
+
/** Takes the lock, listens, and makes the process stop cleanly on SIGTERM and SIGINT. */
|
|
197
|
+
async function run(): Promise<void> {
|
|
198
|
+
const port = options.port ?? Number(process.env['PORT'] ?? 3000)
|
|
199
|
+
const releaseLock = options.acquireLock()
|
|
200
|
+
const runtime = createRuntimeShutdown({
|
|
201
|
+
stopIntake: () => {
|
|
202
|
+
options.stopIntake?.()
|
|
203
|
+
options.backgroundServices?.stop()
|
|
204
|
+
},
|
|
205
|
+
closeServer: () => app.close(),
|
|
206
|
+
...(options.backgroundServices?.drain ? { drain: options.backgroundServices.drain } : {}),
|
|
207
|
+
...(options.shutdownDeadlineMs !== undefined ? { forceExitMs: options.shutdownDeadlineMs } : {}),
|
|
208
|
+
releaseResources: () => {
|
|
209
|
+
options.database.close()
|
|
210
|
+
releaseLock()
|
|
211
|
+
},
|
|
212
|
+
exit: (code) => process.exit(code),
|
|
213
|
+
})
|
|
214
|
+
// onClose runs after the HTTP server has drained, so the DB is no longer in use.
|
|
215
|
+
app.addHook('onClose', async () => {
|
|
216
|
+
runtime.cleanup()
|
|
217
|
+
})
|
|
218
|
+
try {
|
|
219
|
+
await app.listen({ port, host: '0.0.0.0' })
|
|
220
|
+
} catch (error) {
|
|
221
|
+
runtime.cleanup()
|
|
222
|
+
throw error
|
|
223
|
+
}
|
|
224
|
+
console.log(`✓ ${options.logName ?? 'API'} running on http://localhost:${port}`)
|
|
225
|
+
|
|
226
|
+
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
|
227
|
+
process.once(signal, runtime.shutdown)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (options.database.isReady()) options.backgroundServices?.start()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { app, run }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export type BetterApp = Awaited<ReturnType<typeof createBetterApp>>
|
package/src/dataPaths.ts
CHANGED
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
|
|
4
|
-
type Env = NodeJS.ProcessEnv
|
|
5
|
-
|
|
6
|
-
/** Where the application keeps its data: `DATA_DIR`, else `./data` under the working directory. */
|
|
7
|
-
export function dataDir(env: Env = process.env): string {
|
|
8
|
-
return path.resolve(env['DATA_DIR'] ?? path.join(process.cwd(), 'data'))
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** The SQLite file: `DATABASE_PATH`, else `db.sqlite` in the data directory. */
|
|
12
|
-
export function databasePath(env: Env = process.env): string {
|
|
13
|
-
return path.resolve(env['DATABASE_PATH'] ?? path.join(dataDir(env), 'db.sqlite'))
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** The file that records the first-run setup was completed: `SETUP_CONFIG_PATH`, else `setup.json` in the data directory. */
|
|
17
|
-
export function setupConfigPath(env: Env = process.env): string {
|
|
18
|
-
return path.resolve(env['SETUP_CONFIG_PATH'] ?? path.join(dataDir(env), 'setup.json'))
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Where backups are written: `BACKUP_DIR`, else `backups` in the data directory. */
|
|
22
|
-
export function backupDir(env: Env = process.env): string {
|
|
23
|
-
return path.resolve(env['BACKUP_DIR'] ?? path.join(dataDir(env), 'backups'))
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface SetupConfig {
|
|
27
|
-
setupComplete: boolean
|
|
28
|
-
dbType: 'sqlite'
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function isSetupComplete(env: Env = process.env): boolean {
|
|
32
|
-
try {
|
|
33
|
-
const file = setupConfigPath(env)
|
|
34
|
-
if (!fs.existsSync(file)) return false
|
|
35
|
-
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')) as SetupConfig
|
|
36
|
-
return cfg.setupComplete === true
|
|
37
|
-
} catch {
|
|
38
|
-
// A marker that exists but cannot be read is not a fresh install: refuse setup rather than reopen it.
|
|
39
|
-
return true
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function writeSetupComplete(dbType: 'sqlite' = 'sqlite', env: Env = process.env): void {
|
|
44
|
-
const file = setupConfigPath(env)
|
|
45
|
-
const dir = path.dirname(file)
|
|
46
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
47
|
-
fs.writeFileSync(file, JSON.stringify({ setupComplete: true, dbType }, null, 2))
|
|
48
|
-
}
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
type Env = NodeJS.ProcessEnv
|
|
5
|
+
|
|
6
|
+
/** Where the application keeps its data: `DATA_DIR`, else `./data` under the working directory. */
|
|
7
|
+
export function dataDir(env: Env = process.env): string {
|
|
8
|
+
return path.resolve(env['DATA_DIR'] ?? path.join(process.cwd(), 'data'))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The SQLite file: `DATABASE_PATH`, else `db.sqlite` in the data directory. */
|
|
12
|
+
export function databasePath(env: Env = process.env): string {
|
|
13
|
+
return path.resolve(env['DATABASE_PATH'] ?? path.join(dataDir(env), 'db.sqlite'))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The file that records the first-run setup was completed: `SETUP_CONFIG_PATH`, else `setup.json` in the data directory. */
|
|
17
|
+
export function setupConfigPath(env: Env = process.env): string {
|
|
18
|
+
return path.resolve(env['SETUP_CONFIG_PATH'] ?? path.join(dataDir(env), 'setup.json'))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Where backups are written: `BACKUP_DIR`, else `backups` in the data directory. */
|
|
22
|
+
export function backupDir(env: Env = process.env): string {
|
|
23
|
+
return path.resolve(env['BACKUP_DIR'] ?? path.join(dataDir(env), 'backups'))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SetupConfig {
|
|
27
|
+
setupComplete: boolean
|
|
28
|
+
dbType: 'sqlite'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isSetupComplete(env: Env = process.env): boolean {
|
|
32
|
+
try {
|
|
33
|
+
const file = setupConfigPath(env)
|
|
34
|
+
if (!fs.existsSync(file)) return false
|
|
35
|
+
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')) as SetupConfig
|
|
36
|
+
return cfg.setupComplete === true
|
|
37
|
+
} catch {
|
|
38
|
+
// A marker that exists but cannot be read is not a fresh install: refuse setup rather than reopen it.
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeSetupComplete(dbType: 'sqlite' = 'sqlite', env: Env = process.env): void {
|
|
44
|
+
const file = setupConfigPath(env)
|
|
45
|
+
const dir = path.dirname(file)
|
|
46
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
47
|
+
fs.writeFileSync(file, JSON.stringify({ setupComplete: true, dbType }, null, 2))
|
|
48
|
+
}
|
package/src/errorHandler.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Unexpected failures are logged in full but never echoed to the client: driver errors carry the
|
|
5
|
-
* SQL and its parameters, which can include encrypted secrets. Client errors (4xx) keep their
|
|
6
|
-
* message in the `{ error }` shape the admin and status clients read.
|
|
7
|
-
*/
|
|
8
|
-
export function errorHandler(error: FastifyError, req: FastifyRequest, reply: FastifyReply) {
|
|
9
|
-
const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500
|
|
10
|
-
if (statusCode >= 500) {
|
|
11
|
-
req.log.error({ err: error }, 'Unhandled request error')
|
|
12
|
-
return reply.code(statusCode).send({ error: 'Internal Server Error' })
|
|
13
|
-
}
|
|
14
|
-
return reply.code(statusCode).send({ error: error.message })
|
|
15
|
-
}
|
|
1
|
+
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unexpected failures are logged in full but never echoed to the client: driver errors carry the
|
|
5
|
+
* SQL and its parameters, which can include encrypted secrets. Client errors (4xx) keep their
|
|
6
|
+
* message in the `{ error }` shape the admin and status clients read.
|
|
7
|
+
*/
|
|
8
|
+
export function errorHandler(error: FastifyError, req: FastifyRequest, reply: FastifyReply) {
|
|
9
|
+
const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500
|
|
10
|
+
if (statusCode >= 500) {
|
|
11
|
+
req.log.error({ err: error }, 'Unhandled request error')
|
|
12
|
+
return reply.code(statusCode).send({ error: 'Internal Server Error' })
|
|
13
|
+
}
|
|
14
|
+
return reply.code(statusCode).send({ error: error.message })
|
|
15
|
+
}
|