Author SHA1 Message Date
yeuimu 3bbd696b58 chore: ignore deploy/backups 2026-08-27 14:57:19 +08:00
yeuimu b947d88c2c docs(data-cleaning): finalize transformation rules and API-driven approach 2026-08-27 14:57:19 +08:00
yeuimu 1a3858d30b docs(data-cleaning): document transformation rule survey findings 2026-08-27 11:13:05 +08:00
yeuimu a1928a2050 docs(plans): add goods data cleaning feature plan and pipeline folder 2026-08-26 18:36:27 +08:00
yeuimu 005ab5b585 chore: restore files unintentionally deleted in 6c61a4e 2026-08-26 17:54:35 +08:00
yeuimu 6c61a4e871 feat(deploy): production deployment setup and fixes
- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma
  binaryTargets for openssl 3.0
- nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal
  cron, http->https redirect
- prisma: add origin_goods.delisted migration, sync missing schema
  (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz
- api: CORS wildcard reflection, helmet CORP cross-origin, price
  backfill in persistProductDetail, categoryIcon ancestor fallback,
  mediaByColor per-color gallery in public goods detail
- admin: /admin base path (vite + router)
- import-data.mjs: udt_name casting, serial sequence advance fix
2026-08-26 14:23:09 +08:00
yeuimu be0b90e68f feat(security): HttpOnly cookie sessions, token revocation, and RBAC
- Add User.role (enum Role/ADMIN) and User.tokenVersion with migration
- Login now issues short-lived access token (30m default) + 7d refresh
  token, both embedding tokenVersion and a typ discriminator
- Tokens delivered via HttpOnly SameSite cookies (ir_at, ir_rt scoped
  to /auth); refresh token never leaves the cookie
- New endpoints: POST /auth/refresh (rotation), GET /auth/me,
  POST /auth/logout (bumps tokenVersion, revoking all tokens)
- JWT strategy accepts bearer or cookie, rejects refresh tokens, and
  verifies tokenVersion + user existence on every request
- Global RolesGuard: authenticated routes require ADMIN unless widened
  via @Roles(...)
- Admin SPA: session fully cookie-based, no token in localStorage;
  router guard restores session via /auth/me; axios auto-refreshes once
  on 401; stale localStorage keys cleaned up
2026-08-22 12:04:56 +08:00
yeuimu 755b40aded merge: security hardening fixes 2026-08-22 11:55:14 +08:00
yeuimu 9c1106586a fix(security): harden auth, upload, and API configuration
- Lock public registration to first-user bootstrap (403 afterwards)
- Require JwtAuthGuard on upload + whitelist png/jpg/webp/gif (SVG/XSS blocked)
- Add global throttling (login/register 5/min, upload 10/min)
- Add helmet security headers; serve uploads with nosniff
- Replace permissive CORS (origin:true+credentials) with CORS_ORIGINS whitelist
- Disable Swagger outside development; sanitize 500 error responses
- Enforce 32+ char JWT_SECRET; make token expiry configurable (TOKEN_EXPIRES_IN)
- Re-check user in DB on every JWT validation (revocation on user delete)
- Dummy bcrypt compare to prevent login user-enumeration via timing
- Map malformed BigInt inputs to 400 instead of 500
- Widen .gitignore to .env* and add apps/api/.env.example
- Disable Nuxt devtools and sourcemaps
2026-08-22 11:55:13 +08:00
510 changed files with 75234 additions and 65381 deletions
+11 -43
View File
@@ -1,46 +1,14 @@
# Dependencies node_modules/
node_modules dist/
.turbo/
# Build outputs
dist
.output
.nuxt
.nitro
.data
.cache
# TypeScript
*.tsbuildinfo
# Logs
logs
*.log *.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Environment
.env .env
.env.local .env.*
.env.development # Deployment runtime data (certs, ACME state, data dumps)
.env.*.local deploy/certbot/
deploy/data-dump.json
uploads/
# OS # Data cleaning runtime artifacts (production snapshots, exports, reports)
.DS_Store data-cleaning/runs/
deploy/backups/
# IDE
.idea
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
# Coverage
coverage
# Nuxt
.output
.nuxt
.nitro
.cache
+27 -18
View File
@@ -1,5 +1,5 @@
import axios from 'axios' import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios' import type { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import router from '@/router' import router from '@/router'
@@ -9,22 +9,10 @@ const request: AxiosInstance = axios.create({
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
// Session tokens live in HttpOnly cookies — send them along.
withCredentials: true,
}) })
// Request interceptor
request.interceptors.request.use(
(config: AxiosRequestConfig) => {
const token = localStorage.getItem('token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error: AxiosError) => {
return Promise.reject(error)
}
)
// Response interceptor // Response interceptor
request.interceptors.response.use( request.interceptors.response.use(
(response: AxiosResponse) => { (response: AxiosResponse) => {
@@ -35,15 +23,36 @@ request.interceptors.response.use(
} }
return body return body
}, },
(error: AxiosError) => { async (error: AxiosError) => {
// Access token expired: try the refresh cookie once, then retry the
// original request. A second 401 (refresh failed) logs the user out.
const config = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined
if (
error.response?.status === 401 &&
config &&
!config._retried &&
!config.url?.includes('/auth/login') &&
!config.url?.includes('/auth/refresh')
) {
config._retried = true
try {
await axios.post(
`${import.meta.env.VITE_API_BASE || '/api'}/auth/refresh`,
{},
{ withCredentials: true },
)
return request.request(config)
} catch {
// fall through to the 401 handling below
}
}
if (error.response) { if (error.response) {
const { status, data } = error.response const { status, data } = error.response
switch (status) { switch (status) {
case 401: case 401:
ElMessage.error('Unauthorized, please login') ElMessage.error('Unauthorized, please login')
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login') router.push('/login')
break break
case 403: case 403:
+1 -1
View File
@@ -28,7 +28,7 @@ const routes: RouteRecordRaw[] = [
] ]
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory('/admin/'),
routes, routes,
}) })
+23 -32
View File
@@ -4,49 +4,41 @@ import type { LoginRequest, User } from '@/types'
import { authApi } from '@/api/auth' import { authApi } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
// Token persisted to localStorage // The session lives in HttpOnly cookies set by the API; nothing
const token = ref<string>(localStorage.getItem('token') || '') // security-relevant is stored client-side. `user` is just UI state,
// restored from the server via /auth/me on app start.
const user = ref<User | null>(null)
const sessionChecked = ref(false)
// User persisted to localStorage (parsed if available) // Tokens moved to HttpOnly cookies; clean up any stale values from the
const user = ref<User | null>(loadUser()) // previous localStorage-based session.
localStorage.removeItem('token')
localStorage.removeItem('user')
const isLoggedIn = computed(() => !!token.value) const isLoggedIn = computed(() => !!user.value)
function loadUser(): User | null { // Restore the session once per app start. The router guard awaits this
const raw = localStorage.getItem('user') // so a page refresh on a protected route does not bounce to /login.
if (!raw) return null async function ensureSessionChecked() {
if (sessionChecked.value) return
sessionChecked.value = true
try { try {
return JSON.parse(raw) as User user.value = await authApi.getCurrentUser()
} catch { } catch {
return null user.value = null
}
}
function setToken(newToken: string) {
token.value = newToken
if (newToken) {
localStorage.setItem('token', newToken)
} else {
localStorage.removeItem('token')
} }
} }
function setUser(newUser: User | null) { function setUser(newUser: User | null) {
user.value = newUser user.value = newUser
if (newUser) {
localStorage.setItem('user', JSON.stringify(newUser))
} else {
localStorage.removeItem('user')
}
} }
async function login(payload: LoginRequest) { async function login(payload: LoginRequest) {
const res = await authApi.login(payload) as any; const res = await authApi.login(payload) as any
const accessToken: string = res.accessToken ?? res.data?.accessToken ?? ''; const userData: User | null = res.user ?? res.data?.user ?? null
const userData: User | null = res.user ?? res.data?.user ?? null; sessionChecked.value = true
if (accessToken) setToken(accessToken); setUser(userData)
if (userData) setUser(userData); return res
return res;
} }
async function fetchCurrentUser() { async function fetchCurrentUser() {
@@ -61,14 +53,13 @@ export const useAuthStore = defineStore('auth', () => {
} catch { } catch {
// Ignore network errors during logout // Ignore network errors during logout
} }
setToken('')
setUser(null) setUser(null)
} }
return { return {
token,
user, user,
isLoggedIn, isLoggedIn,
ensureSessionChecked,
login, login,
fetchCurrentUser, fetchCurrentUser,
logout, logout,
+1
View File
@@ -7,6 +7,7 @@ import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
base: '/admin/',
plugins: [ plugins: [
vue(), vue(),
AutoImport({ AutoImport({
+20
View File
@@ -0,0 +1,20 @@
# Prisma connection string (PostgreSQL)
DATABASE_URL=postgresql://postgres:CHANGE_ME@localhost:5432/inkreach-official-website
# JWT signing secret: generate with `node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"`
# Must be at least 32 characters.
JWT_SECRET=CHANGE_ME_TO_A_STRONG_RANDOM_SECRET
# Access token lifetime (e.g. 30m, 12h); short-lived, rotated via /auth/refresh
TOKEN_EXPIRES_IN=30m
# Refresh token lifetime (HttpOnly cookie)
REFRESH_TOKEN_EXPIRES_IN=7d
# Comma-separated list of allowed CORS origins (leave empty to disable CORS)
CORS_ORIGINS=http://localhost:5173
# Global rate limit per minute (per IP)
THROTTLE_LIMIT=120
PORT=3001
+4
View File
@@ -34,13 +34,16 @@
"@nestjs/platform-express": "^10.3.0", "@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^4.0.0", "@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.1.17", "@nestjs/swagger": "^7.1.17",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.8.0", "@prisma/client": "^5.8.0",
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
"axios": "^1.6.5", "axios": "^1.6.5",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"cookie-parser": "^1.4.7",
"express": "^4.21.0", "express": "^4.21.0",
"helmet": "^8.3.0",
"multer": "^2.2.0", "multer": "^2.2.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@@ -52,6 +55,7 @@
"@nestjs/schematics": "^10.0.3", "@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.3.0", "@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/node": "^20.10.6", "@types/node": "^20.10.6",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "origin_goods" ADD COLUMN "delisted" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,22 @@
-- Sync database with schema.prisma (missing columns/table from earlier iterations)
-- AlterTable
ALTER TABLE "goods" ADD COLUMN "good_image" TEXT;
-- AlterTable
ALTER TABLE "tags" ADD COLUMN "tag_font_color" TEXT;
-- CreateTable
CREATE TABLE "good_tags" (
"good_id" BIGINT NOT NULL,
"tag_id" BIGINT NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "good_tags_pkey" PRIMARY KEY ("good_id","tag_id")
);
-- CreateIndex
CREATE INDEX "good_tags_tag_id_idx" ON "good_tags"("tag_id");
-- AddForeignKey
ALTER TABLE "good_tags" ADD CONSTRAINT "good_tags_good_id_fkey" FOREIGN KEY ("good_id") REFERENCES "goods"("good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
ALTER TABLE "good_tags" ADD CONSTRAINT "good_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE CASCADE ON UPDATE NO ACTION;
@@ -0,0 +1,8 @@
-- Create enum for user roles
CREATE TYPE "Role" AS ENUM ('ADMIN');
-- Add role column, existing users become ADMIN
ALTER TABLE "users" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'ADMIN';
-- Token version for JWT revocation (logout bumps it)
ALTER TABLE "users" ADD COLUMN "token_version" INTEGER NOT NULL DEFAULT 0;
+8
View File
@@ -6,6 +6,7 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
} }
datasource db { datasource db {
@@ -241,10 +242,17 @@ model GoodTag {
} }
// ---------- Users (admin authentication) ---------- // ---------- Users (admin authentication) ----------
enum Role {
ADMIN
}
model User { model User {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
username String @unique username String @unique
passwordHash String @map("password_hash") passwordHash String @map("password_hash")
role Role @default(ADMIN)
// Bumped on logout / revocation; JWTs carrying an older version are rejected.
tokenVersion Int @default(0) @map("token_version")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
+65
View File
@@ -0,0 +1,65 @@
/**
* Export all application tables from the local (source) database to a JSON
* file, preserving column types for the matching import-data.mjs script.
*
* Usage (from apps/api, against the local DB in .env):
* node scripts/export-data.mjs <output.json>
*/
import { PrismaClient } from '@prisma/client';
import { writeFileSync } from 'node:fs';
const TABLES = [
'users',
'countries',
'categories',
'tag_groups',
'tags',
'positions',
'origin_goods',
'origin_good_variants',
'origin_good_details',
'goods',
'good_tags',
'sync_logs',
];
const prisma = new PrismaClient();
// Serialize values losslessly; import side uses information_schema to restore types.
function serialize(value) {
if (value === null || value === undefined) return null;
if (typeof value === 'bigint') return value.toString();
if (value instanceof Date) return value.toISOString();
if (typeof value === 'object' && Buffer.isBuffer(value)) return value.toString('base64');
if (typeof value === 'object') return JSON.stringify(value); // jsonb
return value;
}
async function main() {
const out = process.argv[2];
if (!out) {
console.error('Usage: node scripts/export-data.mjs <output.json>');
process.exit(1);
}
const dump = { exportedAt: new Date().toISOString(), tables: {} };
for (const table of TABLES) {
const rows = await prisma.$queryRawUnsafe(`SELECT * FROM "${table}"`);
dump.tables[table] = rows.map((row) => {
const o = {};
for (const [k, v] of Object.entries(row)) o[k] = serialize(v);
return o;
});
console.log(`${table}: ${rows.length} rows`);
}
writeFileSync(out, JSON.stringify(dump));
console.log(`Wrote ${out}`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+106
View File
@@ -0,0 +1,106 @@
/**
* Import a dump produced by export-data.mjs into the current database.
* Tables are truncated first (order-independent via session_replication_role)
* and columns are cast back to their real types using information_schema.
*
* Usage (inside the api container):
* node scripts/import-data.mjs <dump.json>
*/
import { PrismaClient, Prisma } from '@prisma/client';
import { readFileSync } from 'node:fs';
const prisma = new PrismaClient();
function toLiteral(value, udtName) {
if (value === null) return Prisma.sql`NULL`;
const target = Prisma.raw(`"${udtName}"`);
switch (udtName) {
case 'int2':
case 'int4':
case 'int8':
return Prisma.sql`${BigInt(value)}::${target}`;
case 'float4':
case 'float8':
case 'numeric':
return Prisma.sql`${Number(value)}::${target}`;
case 'bool':
return Prisma.sql`${!!value}::${target}`;
case 'timestamptz':
case 'timestamp':
return Prisma.sql`${new Date(value).toISOString()}::${target}`;
case 'date':
return Prisma.sql`${String(value)}::${target}`;
case 'jsonb':
case 'json':
return Prisma.sql`${typeof value === 'string' ? value : JSON.stringify(value)}::${target}`;
case 'bytea':
return Prisma.sql`${Buffer.from(value, 'base64')}::bytea`;
default:
// text, varchar, enums and anything else: pass as text and cast
return Prisma.sql`${String(value)}::${target}`;
}
}
async function main() {
const file = process.argv[2];
if (!file) {
console.error('Usage: node scripts/import-data.mjs <dump.json>');
process.exit(1);
}
const dump = JSON.parse(readFileSync(file, 'utf8'));
// Suspend FK checks during bulk load (postgres superuser not required for
// session_replication_role in the compose postgres where app user owns db).
await prisma.$executeRawUnsafe(`SET session_replication_role = replica`);
const summary = {};
for (const [table, rows] of Object.entries(dump.tables)) {
if (rows.length === 0) {
summary[table] = 0;
continue;
}
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
const colTypes = {};
const info = await prisma.$queryRawUnsafe(
`SELECT column_name, udt_name FROM information_schema.columns WHERE table_name = '${table}'`,
);
for (const c of info) colTypes[c.column_name] = c.udt_name;
const columns = Object.keys(rows[0]);
const colList = Prisma.raw(columns.map((c) => `"${c}"`).join(', '));
const CHUNK = 200;
for (let i = 0; i < rows.length; i += CHUNK) {
const tuples = rows.slice(i, i + CHUNK).map(
(r) =>
Prisma.sql`(${Prisma.join(
columns.map((c) => toLiteral(r[c], colTypes[c])),
)})`,
);
await prisma.$executeRaw(
Prisma.sql`INSERT INTO ${Prisma.raw(`"${table}"`)} (${colList}) VALUES ${Prisma.join(tuples)}`,
);
}
// Keep sequences ahead of imported serial ids
// Keep serial sequences ahead of imported ids
const idCol = columns.find(
(c) => c === 'id' || c === `${table.replace(/s$/, '')}_id`,
);
if (idCol) {
await prisma.$executeRawUnsafe(
`SELECT setval(pg_get_serial_sequence('"${table}"', '${idCol}'), COALESCE((SELECT MAX("${idCol}") FROM "${table}"), 1))`,
);
}
summary[table] = rows.length;
}
await prisma.$executeRawUnsafe(`SET session_replication_role = DEFAULT`);
console.log('Imported:', JSON.stringify(summary, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+42
View File
@@ -0,0 +1,42 @@
/**
* Set the admin credentials for the production deployment:
* rename/disable any existing admin and create/update user `inkreach`.
*
* Usage (inside the api container): node scripts/set-admin.mjs
* Reads NEW_ADMIN_USER / NEW_ADMIN_PASSWORD from env.
*/
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const username = process.env.NEW_ADMIN_USER;
const password = process.env.NEW_ADMIN_PASSWORD;
if (!username || !password) {
console.error('NEW_ADMIN_USER / NEW_ADMIN_PASSWORD must be set');
process.exit(1);
}
const passwordHash = await bcrypt.hash(password, 10);
await prisma.user.upsert({
where: { username },
create: { username, passwordHash },
update: { passwordHash, tokenVersion: { increment: 1 } },
});
// Remove every other admin so only `inkreach` can sign in.
const others = await prisma.user.deleteMany({
where: { username: { not: username } },
});
const total = await prisma.user.count();
console.log(`Admin '${username}' set. Demoted ${others.count} other user(s). Total users: ${total}.`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+23
View File
@@ -1,7 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { RolesGuard } from './auth/guards/roles.guard';
import { CountriesModule } from './countries/countries.module'; import { CountriesModule } from './countries/countries.module';
import { CategoriesModule } from './categories/categories.module'; import { CategoriesModule } from './categories/categories.module';
import { TagsModule } from './tags/tags.module'; import { TagsModule } from './tags/tags.module';
@@ -18,6 +21,14 @@ import { UploadModule } from './upload/upload.module';
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
}), }),
// Global rate limiting: 120 req/min per IP. Stricter limits are set
// per-endpoint with @Throttle (auth, upload).
ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: Number(process.env.THROTTLE_LIMIT ?? 120),
},
]),
PrismaModule, PrismaModule,
AuthModule, AuthModule,
CountriesModule, CountriesModule,
@@ -31,5 +42,17 @@ import { UploadModule } from './upload/upload.module';
PublicModule, PublicModule,
UploadModule, UploadModule,
], ],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
// Enforces the ADMIN role on every authenticated route unless the
// route widens access with @Roles(...).
provide: APP_GUARD,
useClass: RolesGuard,
},
],
}) })
export class AppModule {} export class AppModule {}
+108 -13
View File
@@ -1,16 +1,28 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { import {
ApiOperation, Body,
ApiResponse, Controller,
ApiTags, Get,
} from '@nestjs/swagger'; HttpCode,
HttpStatus,
Post,
Req,
Res,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { import { LoginResponseDto, UserPublicDto } from './dto/auth-response.dto';
LoginResponseDto, import type { AuthenticatedUser } from './strategies/jwt.strategy';
UserPublicDto,
} from './dto/auth-response.dto'; const ACCESS_TOKEN_COOKIE = 'ir_at';
const REFRESH_TOKEN_COOKIE = 'ir_rt';
const isProd = process.env.NODE_ENV === 'production';
@ApiTags('auth') @ApiTags('auth')
@Controller('auth') @Controller('auth')
@@ -19,19 +31,102 @@ export class AuthController {
@Post('register') @Post('register')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Register a new admin user' }) @Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: 'Register the first admin user (bootstrap only)' })
@ApiResponse({ status: 201, type: UserPublicDto }) @ApiResponse({ status: 201, type: UserPublicDto })
@ApiResponse({ status: 409, description: 'Username already exists' }) @ApiResponse({ status: 409, description: 'Username already exists' })
@ApiResponse({ status: 403, description: 'Registration is disabled once a user exists' })
register(@Body() dto: RegisterDto): Promise<UserPublicDto> { register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
return this.authService.register(dto) as unknown as Promise<UserPublicDto>; return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
} }
@Post('login') @Post('login')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login and obtain a JWT' }) @Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: 'Login and obtain access + refresh tokens' })
@ApiResponse({ status: 200, type: LoginResponseDto }) @ApiResponse({ status: 200, type: LoginResponseDto })
@ApiResponse({ status: 401, description: 'Invalid credentials' }) @ApiResponse({ status: 401, description: 'Invalid credentials' })
login(@Body() dto: LoginDto): Promise<LoginResponseDto> { async login(
return this.authService.login(dto) as unknown as Promise<LoginResponseDto>; @Body() dto: LoginDto,
@Res({ passthrough: true }) res: Response,
): Promise<LoginResponseDto> {
const result = await this.authService.login(dto);
// HttpOnly cookies are the primary session channel for the admin SPA
// (XSS cannot read them). The access token is also returned in the
// body for non-browser API clients.
res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/',
});
res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
// Only ever sent to /auth/refresh and /auth/logout
path: '/auth',
});
// The refresh token deliberately stays HttpOnly-only.
return {
accessToken: result.accessToken,
user: result.user,
} as unknown as LoginResponseDto;
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@ApiOperation({ summary: 'Rotate the refresh token cookie' })
@ApiResponse({ status: 200, type: LoginResponseDto })
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<LoginResponseDto> {
const token = req.cookies?.[REFRESH_TOKEN_COOKIE];
if (!token) {
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
throw new UnauthorizedException('Missing refresh token');
}
const result = await this.authService.refresh(token);
res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/',
});
res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/auth',
});
return {
accessToken: result.accessToken,
user: result.user,
} as unknown as LoginResponseDto;
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Current authenticated user' })
@ApiResponse({ status: 200, type: UserPublicDto })
me(@Req() req: Request & { user: AuthenticatedUser }): Promise<UserPublicDto> {
return this.authService.me(req.user.id) as unknown as Promise<UserPublicDto>;
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Revoke all tokens of the current user' })
async logout(
@Req() req: Request & { user: AuthenticatedUser },
@Res({ passthrough: true }) res: Response,
): Promise<{ success: true }> {
await this.authService.logout(req.user.id);
res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' });
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
return { success: true };
} }
} }
+6 -1
View File
@@ -17,9 +17,14 @@ import { JwtStrategy } from './strategies/jwt.strategy';
if (!secret) { if (!secret) {
throw new Error('JWT_SECRET must be configured'); throw new Error('JWT_SECRET must be configured');
} }
if (secret.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters (use a strong random value)');
}
return { return {
secret, secret,
signOptions: { expiresIn: '7d' }, signOptions: {
expiresIn: config.get<string>('TOKEN_EXPIRES_IN') ?? '7d',
},
}; };
}, },
}), }),
+119 -54
View File
@@ -1,102 +1,167 @@
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule, JwtService } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config'; import { ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
describe('AuthService', () => { describe('AuthService', () => {
let service: AuthService; let service: AuthService;
let prisma: PrismaService; let jwt: JwtService;
const createdUsernames: string[] = []; let prisma: {
user: {
count: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
};
const HASH = bcrypt.hashSync('plain-pwd', 10);
const dbUser = {
id: 1n,
username: 'alice',
passwordHash: HASH,
role: 'ADMIN' as const,
tokenVersion: 0,
createdAt: new Date('2026-01-01T00:00:00Z'),
};
beforeAll(async () => { beforeAll(async () => {
prisma = {
user: {
count: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }),
JwtModule.register({ JwtModule.register({
secret: 'test-secret', secret: 'a'.repeat(32),
signOptions: { expiresIn: '1h' }, signOptions: { expiresIn: '1h' },
}), }),
], ],
providers: [AuthService, PrismaService], providers: [AuthService, { provide: PrismaService, useValue: prisma }],
}).compile(); }).compile();
service = moduleRef.get(AuthService); service = moduleRef.get(AuthService);
prisma = moduleRef.get(PrismaService); jwt = moduleRef.get(JwtService);
await prisma.onModuleInit();
}); });
afterAll(async () => { beforeEach(() => {
// Cleanup created test users jest.clearAllMocks();
if (createdUsernames.length) {
await prisma.user.deleteMany({
where: { username: { in: createdUsernames } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
}); });
describe('register', () => { describe('register', () => {
it('creates a new user and stores a hashed password', async () => { it('creates the first user with a hashed password', async () => {
const username = `test_reg_${Date.now()}`; prisma.user.count.mockResolvedValueOnce(0);
createdUsernames.push(username); prisma.user.findUnique.mockResolvedValueOnce(null);
prisma.user.create.mockResolvedValueOnce(dbUser);
const user = await service.register({ username, password: 'plain-pwd' }); const user = await service.register({ username: 'alice', password: 'plain-pwd' });
expect(user.username).toBe(username); expect(user.username).toBe('alice');
expect(user.id).toBeTruthy(); const created = prisma.user.create.mock.calls[0][0].data;
expect(created.passwordHash).not.toBe('plain-pwd');
await expect(bcrypt.compare('plain-pwd', created.passwordHash)).resolves.toBe(true);
});
const stored = await prisma.user.findUnique({ where: { username } }); it('refuses registration once a user exists (bootstrap lock)', async () => {
expect(stored).not.toBeNull(); prisma.user.count.mockResolvedValueOnce(1);
expect(stored?.passwordHash).not.toBe('plain-pwd'); await expect(
const matches = await bcrypt.compare('plain-pwd', stored!.passwordHash); service.register({ username: 'mallory', password: 'evil-pwd' }),
expect(matches).toBe(true); ).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.create).not.toHaveBeenCalled();
}); });
it('throws ConflictException for duplicate usernames', async () => { it('throws ConflictException for duplicate usernames', async () => {
const username = `test_dup_${Date.now()}`; prisma.user.count.mockResolvedValueOnce(0);
createdUsernames.push(username); prisma.user.findUnique.mockResolvedValueOnce(dbUser);
await service.register({ username, password: 'pwd1234' });
await expect( await expect(
service.register({ username, password: 'pwd5678' }), service.register({ username: 'alice', password: 'pwd5678' }),
).rejects.toBeInstanceOf(ConflictException); ).rejects.toBeInstanceOf(ConflictException);
}); });
}); });
describe('login', () => { describe('login', () => {
it('returns an access token for valid credentials', async () => { it('returns access + refresh tokens with tokenVersion and type', async () => {
const username = `test_login_${Date.now()}`; prisma.user.findUnique.mockResolvedValueOnce(dbUser);
createdUsernames.push(username); const result = await service.login({ username: 'alice', password: 'plain-pwd' });
await service.register({ username, password: 'correct-pwd' });
const result = await service.login({ username, password: 'correct-pwd' }); expect(result.user.username).toBe('alice');
expect(result.accessToken).toEqual(expect.any(String)); const access = jwt.decode(result.accessToken) as Record<string, unknown>;
const parts = result.accessToken.split('.'); expect(access.typ).toBe('access');
expect(parts.length).toBe(3); expect(access.tv).toBe(0);
expect(result.user.username).toBe(username); const refresh = jwt.decode(result.refreshToken) as Record<string, unknown>;
expect(refresh.typ).toBe('refresh');
expect(refresh.tv).toBe(0);
}); });
it('throws UnauthorizedException for wrong password', async () => { it('throws UnauthorizedException for wrong password', async () => {
const username = `test_wrong_${Date.now()}`; prisma.user.findUnique.mockResolvedValueOnce(dbUser);
createdUsernames.push(username);
await service.register({ username, password: 'right-pwd' });
await expect( await expect(
service.login({ username, password: 'wrong-pwd' }), service.login({ username: 'alice', password: 'wrong-pwd' }),
).rejects.toBeInstanceOf(UnauthorizedException); ).rejects.toBeInstanceOf(UnauthorizedException);
}); });
it('throws UnauthorizedException for unknown user', async () => { it('throws UnauthorizedException for unknown user', async () => {
prisma.user.findUnique.mockResolvedValueOnce(null);
await expect( await expect(
service.login({ username: 'no-such-user-xyz', password: 'whatever' }), service.login({ username: 'no-such-user', password: 'whatever' }),
).rejects.toBeInstanceOf(UnauthorizedException); ).rejects.toBeInstanceOf(UnauthorizedException);
}); });
}); });
describe('refresh', () => {
it('rotates a valid refresh token', async () => {
const refreshToken = await jwt.signAsync({
sub: '1',
username: 'alice',
role: 'ADMIN',
tv: 0,
typ: 'refresh',
});
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
const result = await service.refresh(refreshToken);
expect(result.user.username).toBe('alice');
expect(result.accessToken).not.toBe(refreshToken);
});
it('rejects access tokens used as refresh tokens', async () => {
const accessToken = await jwt.signAsync({
sub: '1',
username: 'alice',
role: 'ADMIN',
tv: 0,
typ: 'access',
});
await expect(service.refresh(accessToken)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects refresh tokens with a stale tokenVersion (revoked)', async () => {
const refreshToken = await jwt.signAsync({
sub: '1',
username: 'alice',
role: 'ADMIN',
tv: 0,
typ: 'refresh',
});
// User logged out elsewhere: tokenVersion bumped to 1
prisma.user.findUnique.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 });
await expect(service.refresh(refreshToken)).rejects.toBeInstanceOf(UnauthorizedException);
});
});
describe('logout', () => {
it('bumps tokenVersion to revoke all tokens', async () => {
prisma.user.update.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 });
await service.logout(1n);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 1n },
data: { tokenVersion: { increment: 1 } },
});
});
});
}); });
+108 -12
View File
@@ -1,5 +1,6 @@
import { import {
ConflictException, ConflictException,
ForbiddenException,
Injectable, Injectable,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -13,16 +14,25 @@ import type { JwtPayload } from './strategies/jwt.strategy';
export interface PublicUser { export interface PublicUser {
id: string; id: string;
username: string; username: string;
role: string;
createdAt: string; createdAt: string;
} }
export interface LoginResult { export interface LoginResult {
accessToken: string; accessToken: string;
refreshToken: string;
user: PublicUser; user: PublicUser;
} }
const BCRYPT_ROUNDS = 10; const BCRYPT_ROUNDS = 10;
const TOKEN_EXPIRES_IN = '7d'; const ACCESS_TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN ?? '30m';
const REFRESH_TOKEN_EXPIRES_IN = process.env.REFRESH_TOKEN_EXPIRES_IN ?? '7d';
/**
* Compared against when the username does not exist so that login takes
* the same time either way (prevents user enumeration via timing).
*/
const DUMMY_HASH = '$2b$10$l232BFW3u63Mhfx0BatxUOLtw.qEofG9fNYjLsh2zce7MdIKDAIR6';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -32,10 +42,15 @@ export class AuthService {
) {} ) {}
/** /**
* Registers a brand-new admin user. Throws {@link ConflictException} * Bootstrap-only registration: allowed just while the instance has no
* if the username is already taken. * users. Once an admin exists the endpoint refuses to create accounts
* (use database seeding / an operator flow instead).
*/ */
async register(dto: RegisterDto): Promise<PublicUser> { async register(dto: RegisterDto): Promise<PublicUser> {
const userCount = await this.prisma.user.count();
if (userCount > 0) {
throw new ForbiddenException('Registration is disabled');
}
const existing = await this.prisma.user.findUnique({ const existing = await this.prisma.user.findUnique({
where: { username: dto.username }, where: { username: dto.username },
}); });
@@ -50,37 +65,118 @@ export class AuthService {
} }
/** /**
* Verifies credentials and returns a signed JWT. * Verifies credentials and returns signed access + refresh tokens.
* Both tokens embed the user's tokenVersion so bumping it on the user
* row (logout / revocation) invalidates them immediately.
*/ */
async login(dto: LoginDto): Promise<LoginResult> { async login(dto: LoginDto): Promise<LoginResult> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { username: dto.username }, where: { username: dto.username },
}); });
// Always run a bcrypt compare (against a dummy hash when the user is
// unknown) so response timing cannot be used to enumerate usernames.
const ok = await bcrypt.compare(dto.password, user?.passwordHash ?? DUMMY_HASH);
if (!user || !ok) {
throw new UnauthorizedException('Invalid credentials');
}
return {
accessToken: await this.signAccessToken(user),
refreshToken: await this.signRefreshToken(user),
user: this.toPublic(user),
};
}
/**
* Rotates a refresh token: the old refresh token becomes invalid as
* soon as tokenVersion is bumped (logout, revocation).
*/
async refresh(refreshToken: string): Promise<LoginResult> {
let payload: JwtPayload;
try {
payload = await this.jwt.verifyAsync(refreshToken);
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
if (payload.typ !== 'refresh') {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await this.prisma.user
.findUnique({ where: { id: BigInt(payload.sub) } })
.catch(() => null);
if (!user || user.tokenVersion !== payload.tv) {
throw new UnauthorizedException('Invalid refresh token');
}
return {
accessToken: await this.signAccessToken(user),
refreshToken: await this.signRefreshToken(user),
user: this.toPublic(user),
};
}
/**
* Revokes all tokens of a user by bumping tokenVersion.
*/
async logout(userId: bigint): Promise<void> {
await this.prisma.user.update({
where: { id: userId },
data: { tokenVersion: { increment: 1 } },
});
}
async me(userId: bigint): Promise<PublicUser> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException();
}
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('Invalid credentials');
} }
return this.toPublic(user);
}
private async signAccessToken(user: {
id: bigint;
username: string;
role: string;
tokenVersion: number;
}): Promise<string> {
const payload: JwtPayload = { const payload: JwtPayload = {
sub: user.id.toString(), sub: user.id.toString(),
username: user.username, username: user.username,
role: user.role,
tv: user.tokenVersion,
typ: 'access',
}; };
const accessToken = await this.jwt.signAsync(payload, { return this.jwt.signAsync(payload, {
expiresIn: TOKEN_EXPIRES_IN, expiresIn: ACCESS_TOKEN_EXPIRES_IN,
});
}
private async signRefreshToken(user: {
id: bigint;
username: string;
role: string;
tokenVersion: number;
}): Promise<string> {
const payload: JwtPayload = {
sub: user.id.toString(),
username: user.username,
role: user.role,
tv: user.tokenVersion,
typ: 'refresh',
};
return this.jwt.signAsync(payload, {
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
}); });
return { accessToken, user: this.toPublic(user) };
} }
private toPublic(user: { private toPublic(user: {
id: bigint; id: bigint;
username: string; username: string;
role: string;
createdAt: Date; createdAt: Date;
}): PublicUser { }): PublicUser {
return { return {
id: user.id.toString(), id: user.id.toString(),
username: user.username, username: user.username,
role: user.role,
createdAt: user.createdAt.toISOString(), createdAt: user.createdAt.toISOString(),
}; };
} }
@@ -0,0 +1,9 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
/**
* Restricts a route to the given roles. When omitted, any
* authenticated user with an ADMIN role passes the RolesGuard.
*/
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
@@ -0,0 +1,32 @@
import { ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { RolesGuard } from './roles.guard';
describe('RolesGuard', () => {
let guard: RolesGuard;
beforeEach(() => {
guard = new RolesGuard(new Reflector());
});
const context = (user: unknown, roles?: string[]) =>
({
switchToHttp: () => ({ getRequest: () => ({ user }) }),
getHandler: () => (roles ? { __roles: roles } : {}),
getClass: () => ({}),
}) as never;
it('passes public routes (no authenticated user)', () => {
expect(guard.canActivate(context(undefined))).toBe(true);
});
it('passes ADMIN users by default', () => {
expect(guard.canActivate(context({ id: 1n, username: 'a', role: 'ADMIN' }))).toBe(true);
});
it('blocks users without the ADMIN role', () => {
expect(() => guard.canActivate(context({ id: 1n, username: 'a', role: 'VIEWER' }))).toThrow(
ForbiddenException,
);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import type { AuthenticatedUser } from '../strategies/jwt.strategy';
/**
* Role-based access control. Applied globally: any authenticated user
* reaching a protected route must hold the ADMIN role unless the route
* declares a wider set with @Roles(...). Routes without a JwtAuthGuard
* (public endpoints) have no `request.user` and are skipped here — their
* openness is decided by the controller's own guards.
*/
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{
user?: AuthenticatedUser;
}>();
if (!request.user) {
return true; // public route — no JwtAuthGuard in front
}
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]) ?? ['ADMIN'];
if (!required.includes(request.user.role)) {
throw new ForbiddenException('Insufficient role');
}
return true;
}
}
+46 -5
View File
@@ -2,26 +2,51 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
export const ACCESS_TOKEN_COOKIE = 'ir_at';
export interface AuthenticatedUser {
id: bigint;
username: string;
role: string;
}
/** /**
* Shape of the JWT we issue. * Shape of the JWT we issue.
* *
* `sub` is the user ID as a string (bigints are serialized to strings in JSON). * `sub` is the user ID as a string (bigints are serialized to strings in JSON).
* `typ` distinguishes access tokens from refresh tokens; `tv` is the user's
* tokenVersion and `role` drives the RolesGuard.
*/ */
export interface JwtPayload { export interface JwtPayload {
sub: string; sub: string;
username: string; username: string;
role?: string;
tv?: number;
typ?: 'access' | 'refresh';
} }
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) { constructor(
config: ConfigService,
private readonly prisma: PrismaService,
) {
const secret = config.get<string>('JWT_SECRET'); const secret = config.get<string>('JWT_SECRET');
if (!secret) { if (!secret) {
throw new Error('JWT_SECRET is not configured'); throw new Error('JWT_SECRET is not configured');
} }
if (secret.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters');
}
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Access tokens are accepted from the HttpOnly cookie (browser) or
// the Authorization header (non-browser API clients).
jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.fromAuthHeaderAsBearerToken(),
(req) => req?.cookies?.[ACCESS_TOKEN_COOKIE] ?? null,
]),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: secret, secretOrKey: secret,
}); });
@@ -29,12 +54,28 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
/** /**
* Runs on every authenticated request. The returned object becomes * Runs on every authenticated request. The returned object becomes
* `request.user` for downstream controllers. * `request.user` for downstream controllers. The user and its
* tokenVersion are re-checked in the database so tokens of deleted
* users, logged-out users, or refresh tokens stop working immediately.
*/ */
validate(payload: JwtPayload): { id: bigint; username: string } { async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
if (!payload?.sub || !payload.username) { if (!payload?.sub || !payload.username) {
throw new UnauthorizedException('Invalid token payload'); throw new UnauthorizedException('Invalid token payload');
} }
return { id: BigInt(payload.sub), username: payload.username }; // Refresh tokens must never be accepted as API credentials.
if (payload.typ === 'refresh') {
throw new UnauthorizedException('Invalid token type');
}
const user = await this.prisma.user
.findUnique({ where: { id: BigInt(payload.sub) } })
.catch(() => null);
if (
!user ||
user.username !== payload.username ||
(payload.tv !== undefined && user.tokenVersion !== payload.tv)
) {
throw new UnauthorizedException('Invalid token');
}
return { id: user.id, username: user.username, role: user.role };
} }
} }
@@ -32,9 +32,24 @@ export class HttpExceptionFilter implements ExceptionFilter {
const request = ctx.getRequest<Request>(); const request = ctx.getRequest<Request>();
const status = const status =
exception instanceof HttpException exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR; // Malformed bigint/number inputs (e.g. `BigInt("abc")`) are client
// errors — map them to 400 instead of leaking a 500.
if (
status === HttpStatus.INTERNAL_SERVER_ERROR &&
exception instanceof Error &&
/Cannot convert .+ to (a BigInt|number)/i.test(exception.message)
) {
response.status(HttpStatus.BAD_REQUEST).json({
statusCode: HttpStatus.BAD_REQUEST,
message: 'Invalid numeric identifier',
error: 'BadRequestError',
timestamp: new Date().toISOString(),
path: request.url,
});
return;
}
let message: string | string[] = 'Internal server error'; let message: string | string[] = 'Internal server error';
let error = 'InternalServerError'; let error = 'InternalServerError';
@@ -51,11 +66,15 @@ export class HttpExceptionFilter implements ExceptionFilter {
message = exception.message; message = exception.message;
} }
} else if (exception instanceof Error) { } else if (exception instanceof Error) {
message = exception.message; // Unexpected errors (Prisma, driver, ...) may contain SQL or
error = exception.name; // connection details — never send them to the client.
this.logger.error(
`${request.method} ${request.url} -> ${status} ${exception.message}`,
exception.stack,
);
} }
if (status >= 500) { if (status >= 500 && exception instanceof HttpException) {
this.logger.error( this.logger.error(
`${request.method} ${request.url} -> ${status} ${message}`, `${request.method} ${request.url} -> ${status} ${message}`,
exception instanceof Error ? exception.stack : undefined, exception instanceof Error ? exception.stack : undefined,
+44 -16
View File
@@ -2,6 +2,8 @@ import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import * as cookieParserModule from 'cookie-parser';
import { json } from 'express'; import { json } from 'express';
import { join } from 'path'; import { join } from 'path';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
@@ -28,15 +30,39 @@ async function bootstrap() {
}), }),
); );
// CORS // Security headers (X-Content-Type-Options, X-Frame-Options, CSP, HSTS, ...)
app.enableCors({ // Static assets (/uploads, /assets) are embedded cross-origin by other
origin: true, // sites, so CORP must allow cross-origin reads.
credentials: true, app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
});
// Serve uploaded files // Parse auth cookies (HttpOnly access/refresh tokens). Resolve both the
// namespace and its `default` interop shape so it works regardless of
// the compiled module interop mode.
const cookieParser = (
cookieParserModule as unknown as {
default?: typeof cookieParserModule;
}
).default ?? cookieParserModule;
app.use(cookieParser());
// CORS: only origins listed in CORS_ORIGINS (comma-separated) are
// allowed. Credentials are enabled because the session lives in
// HttpOnly cookies. "*" disables the allowlist and reflects any origin
// (reflected origins are required when credentials are enabled).
const corsOrigins = (process.env.CORS_ORIGINS ?? '')
.split(',')
.map((o) => o.trim())
.filter(Boolean);
const origin = corsOrigins.includes('*') ? true : corsOrigins;
app.enableCors(corsOrigins.length > 0 ? { origin, credentials: true } : undefined);
// Serve uploaded files. nosniff prevents browsers from sniffing a
// non-image content type out of an uploaded file.
app.useStaticAssets(join(process.cwd(), 'uploads'), { app.useStaticAssets(join(process.cwd(), 'uploads'), {
prefix: '/uploads/', prefix: '/uploads/',
setHeaders: (res) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
},
}); });
app.useStaticAssets(join(process.cwd(), 'public'), { app.useStaticAssets(join(process.cwd(), 'public'), {
prefix: '/assets/', prefix: '/assets/',
@@ -55,21 +81,23 @@ async function bootstrap() {
app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor()); app.useGlobalInterceptors(new TransformInterceptor());
// Swagger // Swagger is only exposed outside production to avoid leaking the
const config = new DocumentBuilder() // full admin API surface.
.setTitle('InkReach Product Center API') if (process.env.NODE_ENV !== 'production') {
.setDescription('Backend API for InkReach Product Center') const config = new DocumentBuilder()
.setVersion('1.0') .setTitle('InkReach Product Center API')
.addBearerAuth() .setDescription('Backend API for InkReach Product Center')
.build(); .setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);
}
const port = process.env.PORT ?? 3001; const port = process.env.PORT ?? 3001;
await app.listen(port, '0.0.0.0'); await app.listen(port, '0.0.0.0');
console.log(`🚀 Application is running on: http://0.0.0.0:${port}`); console.log(`🚀 Application is running on: http://0.0.0.0:${port}`);
console.log(`📚 Swagger documentation: http://0.0.0.0:${port}/api/docs`);
} }
// Make JSON.stringify aware of BigInt so outgoing responses containing // Make JSON.stringify aware of BigInt so outgoing responses containing
@@ -20,6 +20,14 @@ export class PublicGoodDetailDto extends PublicGoodDto {
@ApiProperty({ nullable: true, type: Object }) @ApiProperty({ nullable: true, type: Object })
media!: Record<string, unknown> | null; media!: Record<string, unknown> | null;
@ApiProperty({ type: Array, description: 'Variant images grouped by color' })
mediaByColor!: Array<{
colorId: string | null;
colorName: string | null;
colorHex: string | null;
images: string[];
}>;
@ApiProperty({ nullable: true, type: Object }) @ApiProperty({ nullable: true, type: Object })
options!: Record<string, unknown> | null; options!: Record<string, unknown> | null;
+64 -1
View File
@@ -205,7 +205,9 @@ export class PublicService {
if (!good) { if (!good) {
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' }); throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
} }
return this.toPublicGoodDetail(good); const dto = this.toPublicGoodDetail(good);
dto.category.categoryIcon = await this.resolveCategoryIcon(good.category);
return dto;
} }
async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> { async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
@@ -270,6 +272,66 @@ export class PublicService {
}; };
} }
/** Group distinct variant images by color so the frontend can switch media per color.
* Only color-specific photos are included (main / result / detail images);
* design-layer素材图 and the product-level blank garment photo are excluded
* because they are not per-color gallery photos. */
private groupImagesByColor(
variants: PublicGoodRow['originGood']['variants'],
): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> {
const groups = new Map<string, {
colorId: string | null;
colorName: string | null;
colorHex: string | null;
images: string[];
}>();
for (const variant of variants) {
const key = variant.colorId ?? `variant:${variant.sdsVariantId}`;
let group = groups.get(key);
if (!group) {
group = {
colorId: variant.colorId,
colorName: variant.colorName,
colorHex: variant.colorHex,
images: [],
};
groups.set(key, group);
}
const design = (variant.designData ?? {}) as {
detailImgUrls?: Array<{ imageUrl?: unknown }>;
prototypeResultGroups?: Array<{ resultImage?: unknown }>;
};
const urls: unknown[] = [
variant.imageUrl,
...(design.prototypeResultGroups ?? []).map((item) => item?.resultImage),
...(design.detailImgUrls ?? []).map((image) => image?.imageUrl),
];
for (const url of urls) {
const value = typeof url === 'string' ? url.trim() : '';
if (value && !group.images.includes(value)) {
group.images.push(value);
}
}
}
return [...groups.values()];
}
/** Leaf categories often have no icon upstream; fall back to the nearest ancestor that has one. */
private async resolveCategoryIcon(category: PublicGoodRow['category']): Promise<string | null> {
if (category.categoryIcon) return category.categoryIcon;
let cursor = category.parentCategoryId;
for (let depth = 0; cursor !== null && depth < 10; depth++) {
const parent = await this.prisma.category.findUnique({
where: { id: cursor },
select: { categoryIcon: true, parentCategoryId: true },
});
if (!parent) break;
if (parent.categoryIcon) return parent.categoryIcon;
cursor = parent.parentCategoryId;
}
return null;
}
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto { private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
const base = this.toPublicGood(good); const base = this.toPublicGood(good);
const detail = good.originGood.detail; const detail = good.originGood.detail;
@@ -292,6 +354,7 @@ export class PublicService {
pictureRequest: detail?.pictureRequest ?? null, pictureRequest: detail?.pictureRequest ?? null,
}, },
media: (detail?.media as Record<string, unknown> | null) ?? null, media: (detail?.media as Record<string, unknown> | null) ?? null,
mediaByColor: this.groupImagesByColor(good.originGood.variants),
options: (detail?.options as Record<string, unknown> | null) ?? null, options: (detail?.options as Record<string, unknown> | null) ?? null,
sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null, sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null,
packageSpecs: (detail?.packageSpecs as Record<string, unknown> | null) ?? null, packageSpecs: (detail?.packageSpecs as Record<string, unknown> | null) ?? null,
+7
View File
@@ -608,6 +608,13 @@ export class SyncService {
const { variants, ...detail } = normalized; const { variants, ...detail } = normalized;
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
const json = (value: Prisma.InputJsonValue | null) => value ?? Prisma.DbNull; const json = (value: Prisma.InputJsonValue | null) => value ?? Prisma.DbNull;
// Backfill the origin good's price from upstream min_price when present
if (upstream.min_price !== undefined && upstream.min_price !== null) {
await tx.originGood.update({
where: { id: originGoodId },
data: { goodPrice: new Prisma.Decimal(Number(upstream.min_price)) },
});
}
await tx.originGoodDetail.upsert({ await tx.originGoodDetail.upsert({
where: { originGoodId }, where: { originGoodId },
create: { create: {
+15 -3
View File
@@ -1,33 +1,45 @@
import { import {
Controller, Controller,
Post, Post,
UseGuards,
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import { extname, join } from 'path'; import { extname, join } from 'path';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
const UPLOAD_DIR = join(process.cwd(), 'uploads'); const UPLOAD_DIR = join(process.cwd(), 'uploads');
// Explicit safe-image whitelist. SVG is deliberately excluded: it can
// carry scripts and is served from the same origin (stored XSS).
const ALLOWED_EXTENSIONS = /\.(png|jpe?g|webp|gif)$/i;
const ALLOWED_MIMETYPES = /^image\/(png|jpe?g|webp|gif)$/i;
@UseGuards(JwtAuthGuard)
@Controller('upload') @Controller('upload')
export class UploadController { export class UploadController {
@Post('image') @Post('image')
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@UseInterceptors( @UseInterceptors(
FileInterceptor('file', { FileInterceptor('file', {
storage: diskStorage({ storage: diskStorage({
destination: UPLOAD_DIR, destination: UPLOAD_DIR,
filename: (_req, file, cb) => { filename: (_req, file, cb) => {
const ext = extname(file.originalname) || '.png'; const ext = ALLOWED_EXTENSIONS.test(extname(file.originalname))
? extname(file.originalname).toLowerCase()
: '.png';
cb(null, `${randomUUID()}${ext}`); cb(null, `${randomUUID()}${ext}`);
}, },
}), }),
limits: { fileSize: 5 * 1024 * 1024 }, limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
if (!file.mimetype.startsWith('image/')) { if (!ALLOWED_EXTENSIONS.test(file.originalname) || !ALLOWED_MIMETYPES.test(file.mimetype)) {
return cb(new BadRequestException('仅支持图片文件'), false); return cb(new BadRequestException('仅支持 png/jpg/webp/gif 图片'), false);
} }
cb(null, true); cb(null, true);
}, },
View File
View File
+2 -1
View File
@@ -1,6 +1,7 @@
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
devtools: { enabled: true }, devtools: { enabled: false },
sourcemap: { server: false, client: false },
modules: ['@nuxtjs/seo'], modules: ['@nuxtjs/seo'],
+202
View File
@@ -0,0 +1,202 @@
# 商品数据清洗(Goods Data Cleaning
本目录存放商品数据清洗相关的全部脚本与规则文件。清洗目标:把右侧 SDS 原产品库
`origin_goods`)按规则批量配置为左侧官网商品(`goods`),替代后台手动逐个点击。
## 转换规则调查(2026-08-27,基于生产环境只读数据)
> 数据来源:`https://official.inkreach.cc/api` 生产接口(JWT 只读拉取):
> `/origin-goods?page=1..3&pageSize=200`(共 552 条)、`/goods?page=1..2&pageSize=200`(共 240 条)、
> `/tags`、`/categories`、`/countries`。未做任何写操作。
> 原始 JSON 快照存于 `runs/survey-2026-08-27/`(已加入 .gitignore,不入库)。
### 1. 右侧原产品(origin_goods)命名结构
右侧只有**一个字符串** `goodName`,整体模式:
```
国家(物流备注)品名-SKU-工艺位置[-仓库名]
↑ ↑ ↑ ↑ ↑
| | | | └─ 可选第4段:美西洛杉矶一仓 / 美中亚特兰大仓 等
| | | └─ 单面印花 / 双面印花 / 直喷双面 / 直喷单面 / 不打印 ...
| | └─ SKU 编码(字母+数字,如 DG001 / C1717 / KRHM003
| └─ 包邮 / 不包邮 / DHL包邮*运费结算订单时支付 / 不包邮光板 等变体
└─ 美国/德国/韩国...(全角括号为主,少量半角混用)
```
统计(552 条):
- **541 条**匹配 `国家(备注)其余` 模式;**11 条异常**
- 4 条自定义商品(`Goods Origin ...`source=CUSTOM
- 2 条缺国家前缀:`(不包邮)230g水洗炒雪花T恤-双面印花` / `(不包邮)180g纯棉T恤成人款-...`
- 1 条用空格代替括号:`法国 180g纯棉T恤-FRTM001-双面印花`
- 2 条半角左括号混用:`美国(不包邮)女士高弹中长款瑜伽运动裤-JSD004-...`
- 2 条同上变体
- 「备注)之后」按 `-` 分段:**387 条 = 3 段**(品名-SKU-工艺)、**154 条 = 4 段**(品名-SKU-工艺-仓库)
- 物流备注出现的变体形态(示例):`包邮``不包邮``DHL包邮``DHL包邮*运费结算订单时支付``包邮*运费订单结算时支付``不包邮光板`
- 国家前缀出现过的值:美国、德国、英国、意大利、西班牙、西班牙直发、韩国、日本、加拿大、墨西哥、巴西、波兰、澳大利亚
### 2. 左侧已配置商品(goods)现状 —— 人工配置的实际结果
240 条 goods 记录,覆盖 **209 个**不同 origin_good_id**343 个 origin 尚未配置**)。
**没有系统级拆分逻辑**:前端 [GoodsView.vue](../apps/admin/src/views/goods/GoodsView.vue) 提交的是完整原名,
后端 [goods.service.ts](../apps/api/src/goods/goods.service.ts) create/batchCreate 也原样入库。
因此左侧名称与右端的差异(113/240 条被改过)全部是**人工在编辑弹窗里改的**,且存在多种风格并存。
改名风格分布(113 条改名的归类):
| 风格 | 数量 | 示例 |
|------|------|------|
| A. 全拆:去前缀去括号,品名+空格+SKU,丢弃工艺段 | 27 | `韩国(包邮)200g纯棉T恤-KRTM001-单面印花``200g纯棉T恤 KRTM001` |
| B. 半改:仅把长备注缩成「X包邮」,其余原样保留 | 86 | `德国(DHL包邮*运费结算订单时支付)230g水洗T恤-DETM002-单面印花``德国(DHL包邮)230g水洗T恤-DETM002-单面印花` |
| R0. 保持原名不动 | 127 | 多为后期配置(2026-06 后期 ~ 07),未加工 |
标签体系(生产库现有 3 组 7 个标签)与左侧使用情况:
| 分组 | 标签(id) | 左侧使用次数 |
|------|----------|--------------|
| 物流渠道 | 包邮(30) / 不包邮(31) | 87 / 153 ← 基本每个商品都挂了物流标签 |
| 印刷位置 | 双面印(35) / 单面印(34) | 14 / 14 ← 只有少数挂了 |
| 印刷工艺 | 烫画(32) / 直喷(33) / 不打印(42) | 11 / 3 / 1 |
其他字段:goodPriority 几乎全部为 5positionId 全部为空;
国家字段与名称里的国家前缀一致率 238/240(仅 `西班牙直发(...)` 两条归入了「西班牙」)。
### 3. 人工操作流程(脚本要模拟的完整动作)
后台右侧树有「仅未配置」筛选按钮,人工实际是**两步操作**:
**第一步:配置(右→左创建)**
1. 右侧找到未配置的原产品,点击「配置」
2. 弹窗显示:原产品名(**纯文本,不可编辑**,[L1716](../apps/admin/src/views/goods/GoodsView.vue#L1716))、预览图片(可改但默认回填)
3. 人工选择三项:**国家、分类、标签**(名称在此步不能改)
4. 确认提交 → `POST /goods`,goodName 直接传原产品完整原名([L403](../apps/admin/src/views/goods/GoodsView.vue#L403)
**第二步:编辑改名(左侧已有记录上改)**
1. 左侧找到刚配置的商品,点击「编辑」
2. 编辑弹窗有 `el-input` 绑定 goodName[L1821](../apps/admin/src/views/goods/GoodsView.vue#L1821)),这里才能改名
3. 保存 → `PATCH /goods/:id`[L690](../apps/admin/src/views/goods/GoodsView.vue#L690)
因此脚本的**实际操作序列**是:先 `POST /goods`(用原名创建)→ 再 `PATCH /goods/:id`(改名)
弹窗字段与 API 参数对应:
| 步骤 | 弹窗字段 | API 参数 | 说明 |
|------|----------|----------|------|
| 配置 | 国家 | `countryId` | 必填 |
| 配置 | 分类 | `categoryId` | 必填 |
| 配置 | 标签 | `tagIds[]` | 写入 good_tags 中间表 |
| 配置 | 预览图片 | `goodImage` | 默认回填 origin_good.goodImage |
| 配置 | 名称 | `goodName` | **不可编辑**,自动传原名 |
| 编辑 | 名称 | `goodName` | **这一步才能改**,调 PATCH |
| 编辑 | 其余字段 | 同上 | 也可在编辑时调整 |
### 4. 定稿转换规则
> 以下规则已确认,脚本按此执行。
#### 4.1 整体策略
- **方案:API 驱动**(不导出/导入 DB,避免 BigInt 自增序列和外键约束问题)
- 脚本调用 `POST /goods` 逐条配置,走现有业务逻辑(校验、事务、标签关联全部由后端处理)
- 幂等:已存在的 (originGoodId, countryId) 组合跳过
- scope:仅处理**未配置**的原产品(configuredCount === 0),已配置的不动
#### 4.2 名称解析与改名规则
原产品名格式:`国家(物流备注)品名-SKU-工艺位置[-仓库名]`
**改名规则**:保留到 `-` 分隔的第 2 段,用空格连接,丢弃后续段(工艺、仓库)。
```
输入: 美国(包邮)180g纯棉T恤成人款-DG001-单面印花
解析: 品名="180g纯棉T恤成人款" SKU="DG001" 工艺="单面印花"(丢弃)
输出: goodName = "180g纯棉T恤成人款 DG001"
```
- 仓库名后缀(第 4 段,如「美西洛杉矶一仓」)直接丢弃
- 异常名称(缺国家前缀、半角括号等)单独输出到报告,不自动处理
#### 4.3 国家
- 从名称第一个 `` 之前提取国家文本
- 通过 `GET /countries` 拿到 countries 表,按 `countryName` 精确匹配 → `countryId`
- 特殊映射:「西班牙直发」→ 匹配「西班牙」
- 匹配不到的 → 输出到报告,不自动处理
#### 4.4 分类(品类)
- 右侧树已经按 categories 树分组(通过 `origin_goods.sds_category_id``categories.sds_category_id` 桥接)
- 同一原产品的分类就是它在右侧树中所挂的分类节点,直接取该节点的 `categoryId`
- 未分类的(sdsCategoryId 无匹配)→ 输出到报告
#### 4.5 标签(3 组,从名称解析)
标签体系(3 组 7 个):
| 分组 | 标签 | id | 解析规则 |
|------|------|----|----------|
| 物流渠道 | 包邮 / 不包邮 | 30 / 31 | 括号内含「包邮」→ 包邮(30),否则 → 不包邮(31) |
| 印刷位置 | 单面印 / 双面印 | 34 / 35 | 工艺段含「单面」→ 单面印(34),含「双面」→ 双面印(35) |
| 印刷工艺 | 烫画 / 直喷 / 不打印 | 32 / 33 / 42 | 工艺段含「直喷」→ 直喷(33);含「不打印」或「光板」→ 不打印(42);其余默认 → 烫画(32) |
工艺段 = `-` 分段的最后一段(去仓库段后),如 `单面印花``直喷双面``不打印``烫画`
解析示例:
```
美国(不包邮)180g纯棉T恤成人款-DG001-单面印花
→ 物流: 不包邮(31), 印刷位置: 单面印(34), 印刷工艺: 烫画(32)
美国(不包邮)207G重磅纯棉T恤-C1717-直喷双面
→ 物流: 不包邮(31), 印刷位置: 双面印(35), 印刷工艺: 直喷(33)
美国(不包邮光板)180g纯棉T恤成人款-DG001-不打印
→ 物流: 不包邮(31), 印刷位置: 跳过(工艺段=不打印无法判断单双面), 印刷工艺: 不打印(42)
```
#### 4.6 其他字段
- `goodImage`:直接用 origin_good.goodImage,不改
- `goodPriority`:默认 5(与现有 236/240 条一致)
- `positionId`:不填(与现有 240/240 条一致)
---
## 执行流水线(API 驱动方案)
```
① 拉取只读数据
调 GET /origin-goods/tree(含 configuredCount)、/tags、/categories、/countries
存到 runs/<date>/snapshot/
② 本地解析脚本 sync-plan.mjs
读取快照 → 筛选未配置 → 逐条解析名称 → 生成待创建指令列表
输出: runs/<date>/plan.json(每条包含 originGoodId/countryId/categoryId/goodName/tagIds/goodImage
+ runs/<date>/plan-report.md(人工检查点:共 N 条待创建、X 条异常需人工确认)
③ 人工审查 plan.json 和 plan-report.md
④ 执行脚本 sync-apply.mjs
读取 plan.json → 逐条 POST /goods(带 JWT,自动刷新 token
每条打印 +/skip/error;失败自动重试 3 次;生成 apply-report.md
⑤ 验证:调 /origin-goods/tree 确认未配置数归零,调 /goods 抽查新记录
```
原则:
- 不导出/导入数据库,全部通过 API 操作
- ② 和 ④ 分离:先生成计划供人工审查,确认后再执行
- 每步产物存 runs/(已 gitignore
- token 30 分钟过期,脚本内自动用 /auth/login 刷新
## 目录内容规划
| 文件 | 状态 | 说明 |
|------|------|------|
| `sync-rules.config.mjs` | 待创建 | 国家映射/异常处理等配置项(解析规则已定稿在上文) |
| `sync-plan.mjs` | 待创建 | 拉取 + 解析 + 生成 plan.json |
| `sync-apply.mjs` | 待创建 | 读取 plan.json + 逐条 POST /goods |
| `runs/` | 已创建 | 调查快照 survey-2026-08-27/ 已存在;后续每次运行产物按日期归档 |
详细实施计划见 [plans/feature/goods-data-cleaning-feature.md](../plans/feature/goods-data-cleaning-feature.md)。
+8318
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
# InkReach Admin - Vite build served by nginx
FROM node:20-alpine AS build
WORKDIR /app
RUN corepack enable
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com COREPACK_NPM_REGISTRY=https://registry.npmmirror.com
ARG VITE_API_BASE=/api
ENV VITE_API_BASE=$VITE_API_BASE
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/admin/package.json apps/admin/
RUN pnpm install --filter @inkreach/admin --frozen-lockfile
COPY apps/admin apps/admin
# vue-tsc full check is skipped: pre-existing type errors unrelated to the build output
RUN pnpm --filter @inkreach/admin exec vite build
FROM nginx:1.27-alpine
COPY --from=build /app/apps/admin/dist /usr/share/nginx/html/admin
COPY deploy/nginx/admin.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+30
View File
@@ -0,0 +1,30 @@
# InkReach API - NestJS + Prisma
FROM rkli954yqvk81y0vwt.xuanyuan.run/library/node:20-bookworm-slim AS build
WORKDIR /app
RUN corepack enable
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com COREPACK_NPM_REGISTRY=https://registry.npmmirror.com
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/api/package.json apps/api/
RUN pnpm install --filter @inkreach/api --frozen-lockfile
COPY apps/api apps/api
# `pnpm deploy` produces a self-contained dir with real files (not pnpm
# symlinks), which survives the Docker COPY into the runtime stage.
RUN pnpm --filter @inkreach/api prisma:generate \
&& pnpm --filter @inkreach/api build \
&& pnpm --filter @inkreach/api deploy --legacy /app/deployed
FROM rkli954yqvk81y0vwt.xuanyuan.run/library/node:20-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
# Prisma engines need openssl to detect the libssl version.
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources \
&& apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/deployed .
RUN mkdir -p uploads public
EXPOSE 3001
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/src/main.js"]
+56
View File
@@ -0,0 +1,56 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: inkreach
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: inkreach
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U inkreach -d inkreach"]
interval: 10s
timeout: 5s
retries: 10
# No ports exposed: only reachable from the compose network
api:
build:
context: ..
dockerfile: deploy/api.Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3001
DATABASE_URL: postgresql://inkreach:${POSTGRES_PASSWORD}@postgres:5432/inkreach
JWT_SECRET: ${JWT_SECRET}
CORS_ORIGINS: "*"
volumes:
- uploads:/app/uploads
admin:
build:
context: ..
dockerfile: deploy/admin.Dockerfile
args:
VITE_API_BASE: /api
restart: unless-stopped
depends_on:
- api
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/admin.conf:/etc/nginx/conf.d/default.conf:ro
- ./certbot/www:/var/www/certbot:ro
- ./certbot/acme:/etc/nginx/certs:ro
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
pgdata:
uploads:
+68
View File
@@ -0,0 +1,68 @@
server {
listen 80;
server_name official.inkreach.cc;
client_max_body_size 10m;
# ACME challenge for cert renewals
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name official.inkreach.cc;
ssl_certificate /etc/nginx/certs/official.inkreach.cc_ecc/fullchain.cer;
ssl_certificate_key /etc/nginx/certs/official.inkreach.cc_ecc/official.inkreach.cc.key;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 10m;
root /usr/share/nginx/html;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Admin SPA
location /admin/ {
try_files $uri $uri/ /admin/index.html;
}
location = /admin {
return 301 /admin/;
}
# API: strip the /api prefix before proxying to the NestJS container
location /api/ {
proxy_pass http://api:3001/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Uploaded files served by the API
location /uploads/ {
proxy_pass http://api:3001/uploads/;
proxy_set_header Host $host;
}
# Static product assets served by the API
location /assets/ {
proxy_pass http://api:3001/assets/;
proxy_set_header Host $host;
}
# Website placeholder until the public site is deployed
location / {
return 302 /admin/;
}
}
+41
View File
@@ -0,0 +1,41 @@
const fs = require('fs');
const f = '/app/dist/src/public/public.service.js';
let s = fs.readFileSync(f, 'utf8');
const start = s.indexOf(' groupImagesByColor(variants');
const end = s.indexOf(' async resolveCategoryIcon(category) {');
if (start < 0 || end < 0) {
console.error('markers not found');
process.exit(1);
}
const repl = ` groupImagesByColor(variants) {
const groups = new Map();
for (const variant of variants) {
const key = variant.colorId ?? \`variant:\${variant.sdsVariantId}\`;
let group = groups.get(key);
if (!group) {
group = { colorId: variant.colorId, colorName: variant.colorName, colorHex: variant.colorHex, images: [] };
groups.set(key, group);
}
const design = (variant.designData ?? {});
const urls = [
variant.imageUrl,
...((design.prototypeResultGroups ?? []).map((i) => i?.resultImage)),
...((design.detailImgUrls ?? []).map((i) => i?.imageUrl)),
];
for (const url of urls) {
const value = typeof url === 'string' ? url.trim() : '';
if (value && !group.images.includes(value)) {
group.images.push(value);
}
}
}
return [...groups.values()];
}
`;
s = s.slice(0, start) + repl + s.slice(end);
s = s.replace(
/mediaByColor: this\.groupImagesByColor\(good\.originGood\.variants[^)]*\),/,
'mediaByColor: this.groupImagesByColor(good.originGood.variants),',
);
fs.writeFileSync(f, s);
console.log('patched OK');
@@ -0,0 +1,280 @@
# Goods Data Cleaning Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 用「备份 → 导出 → 脚本转换 → 导入 → 验证」的流水线,把右侧 SDS 原产品库(`origin_goods`)按规则批量配置为左侧官网商品(`goods`),替代后台手动逐个点击。直接作用于生产库。
**Background:**
- 后台 GoodsView 已实现手动配置能力:右侧树选原产品 → 选国家/品类/标签 → 生成 `goods` 记录。
- 手动逐个点击效率低,需要脚本化批量处理。
- 同步规则(品类映射 / 国家分配 / 标签分配 / 优先级策略)**尚未确定**,将在后续数据清洗计划中明确后填入 `data-cleaning/sync-rules.config.mjs`
**Architecture:** 三步式流水线,复用现有 export/import 脚本,只新写转换脚本与独立规则文件。规则文件占位先行——改规则不改代码。转换脚本为纯函数式(JSON in → JSON out),不直接连接数据库;导入前输出变更摘要报告作为人工检查点;导入前必须完成全量备份保证可回滚。
**Tech Stack:** Node.js ESM 脚本、@prisma/client(仅 export/import 使用)、PostgreSQL。
**目录约定:**
- 清洗相关脚本/规则/产物统一放根目录 `data-cleaning/`(见 [data-cleaning/README.md](../../data-cleaning/README.md)
- 复用现有 [apps/api/scripts/export-data.mjs](../../apps/api/scripts/export-data.mjs) 与 [apps/api/scripts/import-data.mjs](../../apps/api/scripts/import-data.mjs)
---
## 流水线总览
```
[生产库]
│ ① pg_dump 全量备份
② node scripts/export-data.mjs data-cleaning/runs/<date>/export.json
③ node data-cleaning/sync-transform.mjs data-cleaning/runs/<date>/export.json
→ 输出 transformed.json + change-report.md(人工检查点)
④ node scripts/import-data.mjs data-cleaning/runs/<date>/transformed.json
⑤ 验证:行数对比 / 抽查商品配置 / 官网公开 API 抽查
```
### Task 1: 创建规则文件占位 `sync-rules.config.mjs`
**Files:**
- Create: `data-cleaning/sync-rules.config.mjs`
- Test: 无(纯数据文件,由 Task 2 的测试覆盖加载逻辑)
- [x] **Step 1: 创建规则文件骨架**
```js
/**
* 商品数据清洗 — 同步规则配置
*
* 规则尚未确定。确定后只修改本文件,不改 sync-transform.mjs。
* 字段语义在规则确定时补充说明。
*/
export const rules = {
/** 品类映射:SDS 品类 → 本地品类(待定) */
categoryMapping: {
// '<sds_category_id 或名称>': '<本地 category_id 或名称>',
},
/** 国家分配:每条原产品生成哪些国家的 good(待定) */
countryAssignment: {
mode: 'none', // none | all | fixed | perCategory
fixedCountryIds: [],
perCategory: {},
},
/** 标签分配:新 good 挂哪些 tag(待定) */
tagAssignment: {
mode: 'none', // none | fixed | perCategory
fixedTagIds: [],
perCategory: {},
},
/** 优先级策略(待定) */
priority: {
defaultPriority: 0,
},
};
```
- [x] **Step 2: Commit**
```bash
git add data-cleaning/sync-rules.config.mjs
git commit -m "feat(data-cleaning): add sync rules config placeholder"
```
### Task 2: 创建转换脚本 `sync-transform.mjs`TDD
**Files:**
- Create: `data-cleaning/sync-transform.mjs`
- Test: `data-cleaning/sync-transform.test.mjs`
- [x] **Step 1: 写失败测试**
```js
// data-cleaning/sync-transform.test.mjs
import { describe, it, expect } from 'vitest';
import { transform } from './sync-transform.mjs';
import { rules } from './sync-rules.config.mjs';
const baseDump = () => ({
exportedAt: new Date().toISOString(),
tables: {
users: [], countries: [], categories: [], tag_groups: [], tags: [],
positions: [],
origin_goods: [
{ id: '1', sds_good_id: 'SDS-A', good_name: 'A', delisted: 'false', is_custom: 'false' },
{ id: '2', sds_good_id: 'SDS-B', good_name: 'B', delisted: 'true', is_custom: 'false' },
],
origin_good_variants: [], origin_good_details: [],
goods: [], good_tags: [], sync_logs: [],
},
});
describe('transform', () => {
it('rules 为空时原样返回且报告零变更', () => {
const dump = baseDump();
const { result, report } = transform(dump, rules);
expect(result.tables.goods).toHaveLength(0);
expect(report.created).toBe(0);
expect(result.tables.origin_goods).toHaveLength(2);
});
it('跳过已下架原产品', () => {
const dump = baseDump();
const testRules = { ...rules, countryAssignment: { mode: 'all' } };
const { report } = transform(dump, testRules);
// 只有未下架的 id=1 会生成 good
expect(report.created).toBe(1);
expect(report.skippedDelisted).toBe(1);
});
it('幂等:已存在的 (originGoodId,countryId) 不重复创建', () => {
const dump = baseDump();
dump.tables.goods = [
{ id: '100', origin_good_id: '1', country_id: '9', good_name: 'A', good_priority: '0' },
];
dump.tables.countries = [{ id: '9', country_name: 'US' }];
const testRules = {
...rules,
countryAssignment: { mode: 'fixed', fixedCountryIds: ['9'] },
};
const { report } = transform(dump, testRules);
expect(report.created).toBe(0);
expect(report.duplicates).toBe(1);
});
});
```
> 注:若仓库未配置 vitest 运行 `.mjs`,可用 `node --test`node:test)替代,断言等价改写。
- [x] **Step 2: 运行测试确认 RED**
Run: `cd apps/api && npx vitest run ../../data-cleaning/sync-transform.test.mjs`(或 `node --test data-cleaning/`
Expected: FAIL(模块不存在)
- [x] **Step 3: 最小实现**
```js
// data-cleaning/sync-transform.mjs
/**
* 数据转换脚本(纯函数式):读取导出 JSON + 规则文件,
* 输出待导入 JSONtransformed.json)与变更摘要(change-report.md)。
*
* Usage:
* node data-cleaning/sync-transform.mjs <export.json>
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { rules } from './sync-rules.config.mjs';
/** 纯函数:dump + rules → { result, report } */
export function transform(dump, rulesConfig) {
const tables = JSON.parse(JSON.stringify(dump.tables)); // deep clone
const countries = tables.countries;
const existingKeys = new Set(
tables.goods.map((g) => `${g.origin_good_id}:${g.country_id}`),
);
const report = { created: 0, duplicates: 0, skippedDelisted: 0, details: [] };
const activeCountries = countries.filter((c) => c.id);
for (const og of tables.origin_goods) {
if (og.delisted === 'true' || og.delisted === true) {
if (og.is_custom !== 'true' && og.is_custom !== true) report.skippedDelisted++;
continue;
}
let targets = [];
if (rulesConfig.countryAssignment.mode === 'all') {
targets = activeCountries.map((c) => c.id);
} else if (rulesConfig.countryAssignment.mode === 'fixed') {
targets = rulesConfig.countryAssignment.fixedCountryIds;
}
for (const countryId of targets) {
const key = `${og.id}:${countryId}`;
if (existingKeys.has(key)) { report.duplicates++; continue; }
const newId = String(
tables.goods.reduce((m, g) => Math.max(m, Number(g.id || 0)), 0) + 1,
);
tables.goods.push({
id: newId,
origin_good_id: og.id,
country_id: countryId,
category_id: rulesConfig.categoryMapping.default ?? '',
good_name: og.good_name,
good_priority: String(rulesConfig.priority.defaultPriority ?? 0),
});
existingKeys.add(key);
report.created++;
report.details.push(`+ good[${newId}] origin=${og.sds_good_id} country=${countryId}`);
}
}
return { result: { exportedAt: dump.exportedAt, tables }, report };
}
function main() {
const input = process.argv[2];
if (!input) {
console.error('Usage: node data-cleaning/sync-transform.mjs <export.json>');
process.exit(1);
}
const dump = JSON.parse(readFileSync(input, 'utf8'));
const { result, report } = transform(dump, rules);
mkdirSync(`${dirname(input)}/out`, { recursive: true });
writeFileSync(`${dirname(input)}/out/transformed.json`, JSON.stringify(result));
writeFileSync(
`${dirname(input)}/out/change-report.md`,
['# 变更摘要', `- 新增 goods: ${report.created}`, `- 重复跳过: ${report.duplicates}`,
`- 下架跳过: ${report.skippedDelisted}`, '', ...report.details.map((d) => `- ${d}`)].join('\n'),
);
console.log(JSON.stringify(report, null, 2));
}
// 测试环境下不自动执行 main
if (process.env.NODE_ENV !== 'test' && import.meta.url === `file://${process.argv[1]}`) {
main();
}
```
- [x] **Step 4: 运行测试确认 GREEN**
Run: `cd apps/api && npx vitest run ../../data-cleaning/sync-transform.test.mjs`
Expected: PASS3 个用例全过)
- [x] **Step 5: Commit**
```bash
git add data-cleaning/sync-transform.mjs data-cleaning/sync-transform.test.mjs
git commit -m "feat(data-cleaning): add pure transform script with tests"
```
### Task 3: 生产执行手册写入 README 并联调演练
**Files:**
- Modify: `data-cleaning/README.md`
- [x] **Step 1: 补充执行命令段**(备份 / 导出 / 转换 / 人工检查 / 导入 / 验证 六个步骤的确切命令与预期输出)
- [x] **Step 2: 本地或预发演练一次全流程**(规则为空应零变更),确认 change-report 为空、行数一致
- [x] **Step 3: Commit**
```bash
git add data-cleaning/README.md
git commit -m "docs(data-cleaning): add production runbook"
```
---
## 待定事项(规则确定后回填)
| 项 | 状态 | 回填位置 |
|----|------|----------|
| 品类映射规则 | 未定 | `data-cleaning/sync-rules.config.mjs#categoryMapping` |
| 国家分配策略 | 未定 | `...#countryAssignment` |
| 标签分配策略 | 未定 | `...#tagAssignment` |
| 优先级策略 | 未定 | `...#priority` |
| 规则细节文档 | 未定 | 后续数据清洗计划文件夹(本目录)内新建规则说明 md |
+54
View File
@@ -99,6 +99,9 @@ importers:
'@nestjs/swagger': '@nestjs/swagger':
specifier: ^7.1.17 specifier: ^7.1.17
version: 7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) version: 7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler':
specifier: ^6.5.0
version: 6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)
'@prisma/client': '@prisma/client':
specifier: ^5.8.0 specifier: ^5.8.0
version: 5.22.0(prisma@5.22.0) version: 5.22.0(prisma@5.22.0)
@@ -117,9 +120,15 @@ importers:
class-validator: class-validator:
specifier: ^0.14.0 specifier: ^0.14.0
version: 0.14.4 version: 0.14.4
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
express: express:
specifier: ^4.21.0 specifier: ^4.21.0
version: 4.22.1 version: 4.22.1
helmet:
specifier: ^8.3.0
version: 8.3.0
multer: multer:
specifier: ^2.2.0 specifier: ^2.2.0
version: 2.2.0 version: 2.2.0
@@ -148,6 +157,9 @@ importers:
'@types/bcrypt': '@types/bcrypt':
specifier: ^5.0.2 specifier: ^5.0.2
version: 5.0.2 version: 5.0.2
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@4.17.25)
'@types/express': '@types/express':
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.25 version: 4.17.25
@@ -1401,6 +1413,13 @@ packages:
'@nestjs/platform-express': '@nestjs/platform-express':
optional: true optional: true
'@nestjs/throttler@6.5.0':
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
peerDependencies:
'@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
reflect-metadata: ^0.1.13 || ^0.2.0
'@noble/hashes@1.8.0': '@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16} engines: {node: ^14.21.3 || >=16}
@@ -3372,6 +3391,11 @@ packages:
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cookie-parser@1.4.10':
resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==}
peerDependencies:
'@types/express': '*'
'@types/cookiejar@2.1.5': '@types/cookiejar@2.1.5':
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
@@ -4542,6 +4566,13 @@ packages:
cookie-es@3.1.1: cookie-es@3.1.1:
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
cookie-parser@1.4.7:
resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
engines: {node: '>= 0.8.0'}
cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
cookie-signature@1.0.7: cookie-signature@1.0.7:
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
@@ -5488,6 +5519,10 @@ packages:
hast-util-whitespace@3.0.0: hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
helmet@8.3.0:
resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==}
engines: {node: '>=18.0.0'}
hey-listen@1.0.8: hey-listen@1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
@@ -9907,6 +9942,12 @@ snapshots:
optionalDependencies: optionalDependencies:
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
'@nestjs/throttler@6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
'@noble/hashes@1.8.0': {} '@noble/hashes@1.8.0': {}
'@nodable/entities@2.2.0': {} '@nodable/entities@2.2.0': {}
@@ -11774,6 +11815,10 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.19.43 '@types/node': 20.19.43
'@types/cookie-parser@1.4.10(@types/express@4.17.25)':
dependencies:
'@types/express': 4.17.25
'@types/cookiejar@2.1.5': {} '@types/cookiejar@2.1.5': {}
'@types/deep-eql@4.0.2': {} '@types/deep-eql@4.0.2': {}
@@ -13136,6 +13181,13 @@ snapshots:
cookie-es@3.1.1: {} cookie-es@3.1.1: {}
cookie-parser@1.4.7:
dependencies:
cookie: 0.7.2
cookie-signature: 1.0.6
cookie-signature@1.0.6: {}
cookie-signature@1.0.7: {} cookie-signature@1.0.7: {}
cookie@0.7.2: {} cookie@0.7.2: {}
@@ -14247,6 +14299,8 @@ snapshots:
dependencies: dependencies:
'@types/hast': 3.0.5 '@types/hast': 3.0.5
helmet@8.3.0: {}
hey-listen@1.0.8: {} hey-listen@1.0.8: {}
hookable@5.5.3: {} hookable@5.5.3: {}