Add PM2 deployment support
This commit is contained in:
@@ -1,6 +1,19 @@
|
||||
# 可直接复制为 .env 用于本地 Node / PM2 启动。
|
||||
# 公网部署前必须修改初始管理员凭据和对外地址。
|
||||
NODE_ENV=development
|
||||
HOST=127.0.0.1
|
||||
PORT=3000
|
||||
DATA_DIRECTORY=./data
|
||||
# 留空时自动生成并保存到 DATA_DIRECTORY/.app-secret。
|
||||
APP_SECRET=
|
||||
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||
INITIAL_ADMIN_PASSWORD=change-this-admin-password
|
||||
INITIAL_ADMIN_NAME=Administrator
|
||||
SESSION_TTL_HOURS=12
|
||||
TRUST_PROXY=false
|
||||
PUBLIC_RATE_LIMIT_PER_HOUR=20
|
||||
PUBLIC_INSTALLATION_LIMIT_PER_HOUR=8
|
||||
MAX_SCREENSHOT_BYTES=5242880
|
||||
# Docker Compose 使用此域名;Node production 改用 PUBLIC_ORIGIN。
|
||||
FEEDBACK_DOMAIN=feedback.example.com
|
||||
# PUBLIC_ORIGIN=https://feedback.example.com
|
||||
|
||||
@@ -56,31 +56,10 @@ cp .env.example .env
|
||||
编辑 `.env`:
|
||||
|
||||
- 将 `FEEDBACK_DOMAIN` 改为真实域名
|
||||
- 用至少 32 字节的随机值替换 `APP_SECRET`
|
||||
- 设置 `INITIAL_ADMIN_EMAIL` 和 `INITIAL_ADMIN_PASSWORD`
|
||||
- 可按需设置 `INITIAL_ADMIN_NAME`
|
||||
|
||||
可用以下方式生成 secret:
|
||||
|
||||
```sh
|
||||
APP_SECRET="$(openssl rand -hex 32)"
|
||||
sed -i "s|^APP_SECRET=.*|APP_SECRET=${APP_SECRET}|" .env
|
||||
unset APP_SECRET
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
构建镜像并创建首个管理员:
|
||||
|
||||
```sh
|
||||
docker compose build
|
||||
read -rsp "Administrator password: " PRODUCT_ISSUE_ADMIN_PASSWORD
|
||||
echo
|
||||
export PRODUCT_ISSUE_ADMIN_PASSWORD
|
||||
docker compose run --rm \
|
||||
-e PRODUCT_ISSUE_ADMIN_PASSWORD \
|
||||
app node dist/server/server/cli/create-admin.js \
|
||||
--email admin@example.com \
|
||||
--name "MesaLogo Admin"
|
||||
unset PRODUCT_ISSUE_ADMIN_PASSWORD
|
||||
```
|
||||
`APP_SECRET` 可以留空,应用会在数据目录生成并持久化随机 secret。
|
||||
|
||||
启动服务:
|
||||
|
||||
@@ -90,17 +69,23 @@ docker compose ps
|
||||
curl "https://feedback.example.com/health"
|
||||
```
|
||||
|
||||
首次启动只在数据库没有管理员时根据 `.env` 创建初始管理员,不会在后续
|
||||
重启时重置密码。创建完成后建议从 `.env` 删除
|
||||
`INITIAL_ADMIN_PASSWORD`。
|
||||
|
||||
将示例域名替换为 `.env` 中的真实域名。管理后台位于
|
||||
`https://你的域名/admin/`。
|
||||
|
||||
> 管理员创建命令也用于密码重置。重置后,该管理员的所有既有会话会立即
|
||||
> 失效。不要把密码写入 `.env`、命令参数或 Git。
|
||||
> `admin:create` 命令仍可用于创建或重置其他管理员。重置后,该管理员的
|
||||
> 所有既有会话会立即失效。
|
||||
|
||||
## 本地开发
|
||||
|
||||
要求 Node.js 24 或更新版本。
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
# 示例配置可直接启动;对外部署前必须修改初始管理员凭据
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
@@ -108,9 +93,12 @@ npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
开发服务器需要通过进程环境提供 `APP_SECRET` 和其他配置。后端默认监听
|
||||
`127.0.0.1:3000`,管理后台 Vite 服务默认监听 `127.0.0.1:5174` 并代理
|
||||
`/api`:
|
||||
Node 后端和管理员 CLI 会自动读取项目根目录的 `.env`。已由 shell、PM2
|
||||
或系统服务注入的同名环境变量优先,不会被文件中的值覆盖。可通过
|
||||
`ENV_FILE=/path/to/file` 指定其他配置文件。
|
||||
|
||||
后端默认监听 `127.0.0.1:3000`,管理后台 Vite 服务默认监听
|
||||
`127.0.0.1:5174` 并代理 `/api`:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
@@ -118,6 +106,27 @@ npm run dev
|
||||
npm run dev:admin
|
||||
```
|
||||
|
||||
## PM2 运行
|
||||
|
||||
没有 Docker 时可使用 PM2 直接管理构建后的 Node 进程:
|
||||
|
||||
```sh
|
||||
npm install --global pm2
|
||||
cp .env.example .env
|
||||
# 示例配置可直接启动;生产环境必须先修改初始管理员凭据
|
||||
npm run pm2:start
|
||||
pm2 save
|
||||
```
|
||||
|
||||
初始管理员仅在数据库为空时创建。确认可以登录后,建议删除 `.env` 中的
|
||||
`INITIAL_ADMIN_PASSWORD`,再执行 `npm run pm2:reload`。未设置
|
||||
`APP_SECRET` 时,应用会把自动生成的 secret 保存到
|
||||
`DATA_DIRECTORY/.app-secret`。
|
||||
|
||||
更新代码后运行 `npm run pm2:reload`,查看日志使用
|
||||
`pm2 logs product-issue`。默认访问地址为
|
||||
`http://127.0.0.1:3000/admin/`。
|
||||
|
||||
## 文档
|
||||
|
||||
- [公共 API 契约](docs/api.md)
|
||||
|
||||
+4
-1
@@ -22,7 +22,10 @@ services:
|
||||
PORT: 3000
|
||||
PUBLIC_ORIGIN: https://${FEEDBACK_DOMAIN:?FEEDBACK_DOMAIN is required}
|
||||
DATA_DIRECTORY: /app/data
|
||||
APP_SECRET: ${APP_SECRET:?APP_SECRET is required}
|
||||
APP_SECRET: ${APP_SECRET:-}
|
||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
|
||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
|
||||
INITIAL_ADMIN_NAME: ${INITIAL_ADMIN_NAME:-Administrator}
|
||||
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-12}
|
||||
TRUST_PROXY: "true"
|
||||
PUBLIC_RATE_LIMIT_PER_HOUR: ${PUBLIC_RATE_LIMIT_PER_HOUR:-20}
|
||||
|
||||
+73
-2
@@ -2,12 +2,23 @@
|
||||
|
||||
## 配置
|
||||
|
||||
Docker Compose 从项目根目录 `.env` 读取以下值:
|
||||
Docker Compose、直接运行的 Node 服务和管理员 CLI 都会从项目根目录
|
||||
`.env` 读取配置。已由 shell、PM2 或系统服务注入的同名变量优先。设置
|
||||
`ENV_FILE=/path/to/file` 可改用其他配置文件;显式指定的文件不存在时,
|
||||
进程会拒绝启动。
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `NODE_ENV` | 运行环境:`development`、`test` 或 `production` | `development` |
|
||||
| `HOST` | Node 服务监听地址 | `127.0.0.1` |
|
||||
| `PORT` | Node 服务监听端口 | `3000` |
|
||||
| `PUBLIC_ORIGIN` | production 对外 HTTPS origin | production 必填 |
|
||||
| `DATA_DIRECTORY` | SQLite 与附件目录 | `./data` |
|
||||
| `FEEDBACK_DOMAIN` | Caddy 申请证书并对外服务的域名 | 必填 |
|
||||
| `APP_SECRET` | 会话、IP 和安装标识 digest 的主 secret,至少 32 字符 | 必填 |
|
||||
| `APP_SECRET` | 会话、IP 和安装标识 digest 的主 secret,不限制长度 | 自动生成并持久化 |
|
||||
| `INITIAL_ADMIN_EMAIL` | 数据库为空时创建的初始管理员邮箱 | 可选 |
|
||||
| `INITIAL_ADMIN_PASSWORD` | 初始管理员密码,12–128 字符 | 可选 |
|
||||
| `INITIAL_ADMIN_NAME` | 初始管理员显示名 | `Administrator` |
|
||||
| `SESSION_TTL_HOURS` | 管理员会话时长 | `12` |
|
||||
| `PUBLIC_RATE_LIMIT_PER_HOUR` | 单 IP 每小时公开提交上限 | `20` |
|
||||
| `PUBLIC_INSTALLATION_LIMIT_PER_HOUR` | 单安装实例每小时公开提交上限 | `8` |
|
||||
@@ -16,10 +27,70 @@ Docker Compose 从项目根目录 `.env` 读取以下值:
|
||||
`.env` 包含 secret,不得提交到 Git 或复制到工单、聊天和日志中。建议在
|
||||
密码管理器中保存一份加密副本,并在主机上执行 `chmod 600 .env`。
|
||||
|
||||
未设置或留空 `APP_SECRET` 时,应用会生成随机值并以权限 `0600` 保存到
|
||||
`DATA_DIRECTORY/.app-secret`。后续启动会复用该文件。应将此文件与
|
||||
SQLite 和附件一起备份,否则恢复后现有管理员会话将失效。
|
||||
|
||||
`INITIAL_ADMIN_EMAIL` 和 `INITIAL_ADMIN_PASSWORD` 必须同时设置。应用仅在
|
||||
管理员表为空时创建该账号,后续重启不会同步或重置密码。确认初始账号
|
||||
可登录后,应从 `.env` 删除 `INITIAL_ADMIN_PASSWORD`。
|
||||
|
||||
Compose 会从 `FEEDBACK_DOMAIN` 生成
|
||||
`PUBLIC_ORIGIN=https://FEEDBACK_DOMAIN`。直接运行 Node 服务时,
|
||||
production 环境仍必须显式提供只含 HTTPS origin 的 `PUBLIC_ORIGIN`。
|
||||
|
||||
## PM2 部署
|
||||
|
||||
没有 Docker 时,可通过 Node.js 24+ 和 PM2 启动服务。先安装依赖并准备
|
||||
配置:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm install --global pm2
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
复制后的示例配置可以直接以 development 模式启动,初始账号为
|
||||
`admin@example.com`,示例密码仅用于首次本地运行。公网部署前必须修改
|
||||
初始管理员凭据并配置 production 对外地址:
|
||||
|
||||
```dotenv
|
||||
NODE_ENV=production
|
||||
HOST=127.0.0.1
|
||||
PORT=3000
|
||||
PUBLIC_ORIGIN=https://feedback.example.com
|
||||
DATA_DIRECTORY=./data
|
||||
APP_SECRET=
|
||||
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||
INITIAL_ADMIN_PASSWORD=replace-with-a-secure-password
|
||||
INITIAL_ADMIN_NAME=Administrator
|
||||
```
|
||||
|
||||
构建并启动:
|
||||
|
||||
```sh
|
||||
npm run pm2:start
|
||||
pm2 save
|
||||
pm2 status
|
||||
curl --fail "http://127.0.0.1:3000/health"
|
||||
```
|
||||
|
||||
登录成功后删除 `.env` 中的 `INITIAL_ADMIN_PASSWORD`,再执行
|
||||
`npm run pm2:reload`。数据库已有管理员时,即使保留这些变量也不会重置
|
||||
账号,但不应长期把明文密码保存在主机配置中。
|
||||
|
||||
`ecosystem.config.cjs` 固定使用单进程 fork 模式,避免多个进程同时管理单机
|
||||
SQLite 和本地附件。更新代码后执行:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run pm2:reload
|
||||
pm2 logs product-issue --lines 100
|
||||
```
|
||||
|
||||
PM2 只管理 Node 进程,不提供 TLS。公网部署仍须在前方配置 Caddy、Nginx
|
||||
或等效 HTTPS 反向代理,并且只让代理访问监听端口。
|
||||
|
||||
## 网络与 TLS
|
||||
|
||||
- DNS A/AAAA 记录必须指向主机。
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'product-issue',
|
||||
script: 'dist/server/server/main.js',
|
||||
cwd: __dirname,
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '512M',
|
||||
kill_timeout: 10_000
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-1
@@ -9,7 +9,8 @@ export default tseslint.config(
|
||||
'node_modules/**',
|
||||
'coverage/**',
|
||||
'data/**',
|
||||
'eslint.config.js'
|
||||
'eslint.config.js',
|
||||
'ecosystem.config.cjs'
|
||||
]
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
|
||||
Generated
+7
-7
@@ -2224,13 +2224,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.0.tgz",
|
||||
"integrity": "sha512-yZQa2zm87aRVcqDyH5+4Hv9KYgSdgwX1rFnGvpbzMaC7YAljmhBET93TPiTd3ObwTL+gSpIzPKg5BqVxdCvxKg==",
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.8.0"
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
@@ -5166,9 +5166,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
|
||||
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"build:admin": "vite build",
|
||||
"build:server": "tsc -p tsconfig.server.json",
|
||||
"start": "node dist/server/server/main.js",
|
||||
"pm2:start": "npm run build && pm2 start ecosystem.config.cjs",
|
||||
"pm2:reload": "npm run build && pm2 reload ecosystem.config.cjs --update-env",
|
||||
"pm2:stop": "pm2 stop product-issue",
|
||||
"admin:create": "tsx src/server/cli/create-admin.ts",
|
||||
"admin:create:prod": "node dist/server/server/cli/create-admin.js",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.admin.json && tsc --noEmit -p tsconfig.test.json",
|
||||
|
||||
@@ -86,6 +86,21 @@ export class AuthService {
|
||||
})()
|
||||
}
|
||||
|
||||
async createInitialAdmin(input: {
|
||||
email: string
|
||||
displayName: string
|
||||
password: string
|
||||
}): Promise<boolean> {
|
||||
const existingAdmin = this.database
|
||||
.prepare('SELECT 1 FROM admins LIMIT 1')
|
||||
.get()
|
||||
if (existingAdmin) {
|
||||
return false
|
||||
}
|
||||
await this.createAdmin(input)
|
||||
return true
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<CreatedSession | undefined> {
|
||||
this.pruneExpiredSessionsIfDue()
|
||||
const admin = this.database
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import { AuthService } from '../auth-service.js'
|
||||
import { loadConfig } from '../config.js'
|
||||
import { openDatabase } from '../database.js'
|
||||
import { loadEnvironmentFile } from '../environment.js'
|
||||
|
||||
const argumentsSchema = z.object({
|
||||
email: z.email().max(254),
|
||||
@@ -27,6 +28,7 @@ function parseArguments(input: string[]): { email: string; name: string } {
|
||||
|
||||
process.umask(0o077)
|
||||
|
||||
loadEnvironmentFile()
|
||||
const config = loadConfig()
|
||||
const password = process.env['PRODUCT_ISSUE_ADMIN_PASSWORD']
|
||||
if (!password || password.length < 12 || password.length > 128) {
|
||||
|
||||
+108
-10
@@ -1,3 +1,9 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -5,6 +11,18 @@ const booleanEnvironmentSchema = z
|
||||
.enum(['true', 'false'])
|
||||
.transform((value) => value === 'true')
|
||||
|
||||
const optionalEnvironmentString = <T extends z.ZodType>(
|
||||
schema: T
|
||||
) =>
|
||||
z
|
||||
.preprocess(
|
||||
(value) =>
|
||||
typeof value === 'string' && value.trim() === ''
|
||||
? undefined
|
||||
: value,
|
||||
schema
|
||||
)
|
||||
|
||||
const configSchema = z
|
||||
.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
@@ -12,13 +30,15 @@ const configSchema = z
|
||||
PORT: z.coerce.number().int().min(1).max(65_535).default(3_000),
|
||||
PUBLIC_ORIGIN: z.url().optional(),
|
||||
DATA_DIRECTORY: z.string().min(1).default('./data'),
|
||||
APP_SECRET: z
|
||||
.string()
|
||||
.min(32)
|
||||
.refine(
|
||||
(value) =>
|
||||
value !== 'replace-with-at-least-32-random-characters',
|
||||
'APP_SECRET must be a random production secret'
|
||||
APP_SECRET: optionalEnvironmentString(z.string().optional()),
|
||||
INITIAL_ADMIN_EMAIL: optionalEnvironmentString(
|
||||
z.email().max(254).optional()
|
||||
),
|
||||
INITIAL_ADMIN_PASSWORD: optionalEnvironmentString(
|
||||
z.string().min(12).max(128).optional()
|
||||
),
|
||||
INITIAL_ADMIN_NAME: optionalEnvironmentString(
|
||||
z.string().trim().min(1).max(120).optional()
|
||||
),
|
||||
SESSION_TTL_HOURS: z.coerce.number().int().min(1).max(168).default(12),
|
||||
TRUST_PROXY: booleanEnvironmentSchema.default(false),
|
||||
@@ -43,6 +63,17 @@ const configSchema = z
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, context) => {
|
||||
if (
|
||||
Boolean(value.INITIAL_ADMIN_EMAIL) !==
|
||||
Boolean(value.INITIAL_ADMIN_PASSWORD)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['INITIAL_ADMIN_EMAIL'],
|
||||
message:
|
||||
'INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD must be configured together'
|
||||
})
|
||||
}
|
||||
if (value.NODE_ENV === 'production' && !value.PUBLIC_ORIGIN) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
@@ -89,6 +120,59 @@ export type AppConfig = {
|
||||
publicRateLimitPerHour: number
|
||||
publicInstallationLimitPerHour: number
|
||||
maxScreenshotBytes: number
|
||||
initialAdmin?: {
|
||||
email: string
|
||||
displayName: string
|
||||
password: string
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAppSecret(
|
||||
configuredSecret: string | undefined,
|
||||
dataDirectory: string
|
||||
): string {
|
||||
if (configuredSecret !== undefined) {
|
||||
return configuredSecret
|
||||
}
|
||||
|
||||
mkdirSync(dataDirectory, { recursive: true, mode: 0o700 })
|
||||
const secretFile = resolve(dataDirectory, '.app-secret')
|
||||
try {
|
||||
const savedSecret = readFileSync(secretFile, 'utf8').trim()
|
||||
if (savedSecret) {
|
||||
return savedSecret
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
!('code' in error) ||
|
||||
error.code !== 'ENOENT'
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const generatedSecret = randomBytes(32).toString('base64url')
|
||||
try {
|
||||
writeFileSync(secretFile, `${generatedSecret}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx'
|
||||
})
|
||||
return generatedSecret
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'EEXIST'
|
||||
) {
|
||||
const savedSecret = readFileSync(secretFile, 'utf8').trim()
|
||||
if (savedSecret) {
|
||||
return savedSecret
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function loadConfig(
|
||||
@@ -101,6 +185,9 @@ export function loadConfig(
|
||||
PUBLIC_ORIGIN: environment['PUBLIC_ORIGIN'],
|
||||
DATA_DIRECTORY: environment['DATA_DIRECTORY'],
|
||||
APP_SECRET: environment['APP_SECRET'],
|
||||
INITIAL_ADMIN_EMAIL: environment['INITIAL_ADMIN_EMAIL'],
|
||||
INITIAL_ADMIN_PASSWORD: environment['INITIAL_ADMIN_PASSWORD'],
|
||||
INITIAL_ADMIN_NAME: environment['INITIAL_ADMIN_NAME'],
|
||||
SESSION_TTL_HOURS: environment['SESSION_TTL_HOURS'],
|
||||
TRUST_PROXY: environment['TRUST_PROXY'],
|
||||
PUBLIC_RATE_LIMIT_PER_HOUR:
|
||||
@@ -109,6 +196,7 @@ export function loadConfig(
|
||||
environment['PUBLIC_INSTALLATION_LIMIT_PER_HOUR'],
|
||||
MAX_SCREENSHOT_BYTES: environment['MAX_SCREENSHOT_BYTES']
|
||||
})
|
||||
const dataDirectory = resolve(parsed.DATA_DIRECTORY)
|
||||
return {
|
||||
nodeEnv: parsed.NODE_ENV,
|
||||
host: parsed.HOST,
|
||||
@@ -116,13 +204,23 @@ export function loadConfig(
|
||||
...(parsed.PUBLIC_ORIGIN
|
||||
? { publicOrigin: new URL(parsed.PUBLIC_ORIGIN).origin }
|
||||
: {}),
|
||||
dataDirectory: resolve(parsed.DATA_DIRECTORY),
|
||||
appSecret: parsed.APP_SECRET,
|
||||
dataDirectory,
|
||||
appSecret: resolveAppSecret(parsed.APP_SECRET, dataDirectory),
|
||||
sessionTtlMs: parsed.SESSION_TTL_HOURS * 60 * 60 * 1_000,
|
||||
trustProxy: parsed.TRUST_PROXY,
|
||||
publicRateLimitPerHour: parsed.PUBLIC_RATE_LIMIT_PER_HOUR,
|
||||
publicInstallationLimitPerHour:
|
||||
parsed.PUBLIC_INSTALLATION_LIMIT_PER_HOUR,
|
||||
maxScreenshotBytes: parsed.MAX_SCREENSHOT_BYTES
|
||||
maxScreenshotBytes: parsed.MAX_SCREENSHOT_BYTES,
|
||||
...(parsed.INITIAL_ADMIN_EMAIL && parsed.INITIAL_ADMIN_PASSWORD
|
||||
? {
|
||||
initialAdmin: {
|
||||
email: parsed.INITIAL_ADMIN_EMAIL,
|
||||
displayName:
|
||||
parsed.INITIAL_ADMIN_NAME ?? 'Administrator',
|
||||
password: parsed.INITIAL_ADMIN_PASSWORD
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { loadEnvFile } from 'node:process'
|
||||
|
||||
export function loadEnvironmentFile(
|
||||
environmentFile = process.env['ENV_FILE']
|
||||
): string | undefined {
|
||||
const configuredPath = environmentFile?.trim()
|
||||
const filePath = resolve(configuredPath || '.env')
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
if (configuredPath) {
|
||||
throw new Error(`Environment file does not exist: ${filePath}`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
loadEnvFile(filePath)
|
||||
return filePath
|
||||
}
|
||||
@@ -2,14 +2,25 @@ import { existsSync } from 'node:fs'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { buildApp } from './app.js'
|
||||
import { AuthService } from './auth-service.js'
|
||||
import { loadConfig } from './config.js'
|
||||
import { openDatabase } from './database.js'
|
||||
import { loadEnvironmentFile } from './environment.js'
|
||||
|
||||
process.umask(0o077)
|
||||
|
||||
loadEnvironmentFile()
|
||||
const config = loadConfig()
|
||||
await mkdir(config.dataDirectory, { recursive: true, mode: 0o700 })
|
||||
const database = openDatabase(join(config.dataDirectory, 'product-issue.sqlite'))
|
||||
if (config.initialAdmin) {
|
||||
const auth = new AuthService(
|
||||
database,
|
||||
config.appSecret,
|
||||
config.sessionTtlMs
|
||||
)
|
||||
await auth.createInitialAdmin(config.initialAdmin)
|
||||
}
|
||||
const adminDirectory = resolve('dist/admin')
|
||||
const serveAdmin =
|
||||
config.nodeEnv === 'production' ||
|
||||
|
||||
@@ -20,6 +20,36 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('AuthService', () => {
|
||||
it('creates the initial administrator only when the database is empty', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'product-issue-auth-'))
|
||||
directories.push(directory)
|
||||
const database = openDatabase(join(directory, 'database.sqlite'))
|
||||
databases.push(database)
|
||||
const auth = new AuthService(database, 'secret', 60 * 60 * 1_000)
|
||||
|
||||
await expect(
|
||||
auth.createInitialAdmin({
|
||||
email: 'first@example.com',
|
||||
displayName: 'First admin',
|
||||
password: 'first secure password'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
auth.createInitialAdmin({
|
||||
email: 'second@example.com',
|
||||
displayName: 'Second admin',
|
||||
password: 'second secure password'
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
|
||||
await expect(
|
||||
auth.login('first@example.com', 'first secure password')
|
||||
).resolves.toMatchObject({ displayName: 'First admin' })
|
||||
await expect(
|
||||
auth.login('second@example.com', 'second secure password')
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('invalidates existing sessions when an administrator is reset', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'product-issue-auth-'))
|
||||
directories.push(directory)
|
||||
|
||||
+49
-4
@@ -1,7 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { loadConfig } from '../src/server/config.js'
|
||||
|
||||
const secret = 'test-secret-that-is-at-least-thirty-two-characters'
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('normalizes a valid production origin', () => {
|
||||
@@ -40,11 +50,46 @@ describe('loadConfig', () => {
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('rejects the example placeholder secret', () => {
|
||||
it('accepts an explicitly configured secret without a length restriction', () => {
|
||||
expect(loadConfig({ APP_SECRET: 'short' }).appSecret).toBe('short')
|
||||
})
|
||||
|
||||
it('generates and reuses a secret when APP_SECRET is omitted', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'product-issue-config-'))
|
||||
directories.push(directory)
|
||||
|
||||
const first = loadConfig({ DATA_DIRECTORY: directory })
|
||||
const second = loadConfig({ DATA_DIRECTORY: directory })
|
||||
|
||||
expect(first.appSecret).toHaveLength(43)
|
||||
expect(second.appSecret).toBe(first.appSecret)
|
||||
expect(readFileSync(join(directory, '.app-secret'), 'utf8').trim()).toBe(
|
||||
first.appSecret
|
||||
)
|
||||
})
|
||||
|
||||
it('parses initial administrator configuration', () => {
|
||||
expect(
|
||||
loadConfig({
|
||||
APP_SECRET: secret,
|
||||
INITIAL_ADMIN_EMAIL: 'admin@example.com',
|
||||
INITIAL_ADMIN_PASSWORD: 'a local admin password'
|
||||
}).initialAdmin
|
||||
).toEqual({
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Administrator',
|
||||
password: 'a local admin password'
|
||||
})
|
||||
})
|
||||
|
||||
it('requires initial administrator email and password together', () => {
|
||||
expect(() =>
|
||||
loadConfig({
|
||||
APP_SECRET: 'replace-with-at-least-32-random-characters'
|
||||
APP_SECRET: secret,
|
||||
INITIAL_ADMIN_EMAIL: 'admin@example.com'
|
||||
})
|
||||
).toThrow()
|
||||
).toThrow(
|
||||
'INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD must be configured together'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { loadEnvironmentFile } from '../src/server/environment.js'
|
||||
|
||||
const testVariable = 'PRODUCT_ISSUE_ENV_FILE_TEST'
|
||||
const originalValue = process.env[testVariable]
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
if (originalValue === undefined) {
|
||||
delete process.env[testVariable]
|
||||
} else {
|
||||
process.env[testVariable] = originalValue
|
||||
}
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function environmentFile(value: string): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'product-issue-env-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const filePath = join(directory, '.env')
|
||||
writeFileSync(filePath, `${testVariable}=${value}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600
|
||||
})
|
||||
return filePath
|
||||
}
|
||||
|
||||
describe('loadEnvironmentFile', () => {
|
||||
it('loads values from an explicit environment file', () => {
|
||||
delete process.env[testVariable]
|
||||
|
||||
const filePath = environmentFile('from-file')
|
||||
|
||||
expect(loadEnvironmentFile(filePath)).toBe(filePath)
|
||||
expect(process.env[testVariable]).toBe('from-file')
|
||||
})
|
||||
|
||||
it('does not override values already provided by the process', () => {
|
||||
process.env[testVariable] = 'from-process'
|
||||
|
||||
loadEnvironmentFile(environmentFile('from-file'))
|
||||
|
||||
expect(process.env[testVariable]).toBe('from-process')
|
||||
})
|
||||
|
||||
it('fails when an explicitly configured file does not exist', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'product-issue-env-'))
|
||||
temporaryDirectories.push(directory)
|
||||
|
||||
expect(() => loadEnvironmentFile(join(directory, '.env'))).toThrow(
|
||||
'Environment file does not exist'
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user