v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.

세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-22 11:05:40 +09:00
parent 9bc0f0a90e
commit de767095ba
116 changed files with 8128 additions and 2933 deletions

View File

@@ -49,6 +49,11 @@ const envValues = readEnvFile()
const databaseName = process.env.POSTGRES_DB || envValues.POSTGRES_DB || 'sori_studio'
const databaseUser = process.env.POSTGRES_USER || envValues.POSTGRES_USER || 'sori_studio'
if (migrationEnvironment === 'test' && !databaseName.endsWith('_test')) {
console.error(`테스트 DB 이름은 _test로 끝나야 합니다: ${databaseName}`)
process.exit(1)
}
/**
* SQL 문자열 리터럴 이스케이프
* @param {string} value - SQL 문자열 값
@@ -96,7 +101,11 @@ const runCommand = (command, args, options = {}) => {
* @param {string[]} args - compose 하위 명령 인자
* @returns {string[]} 전체 docker compose 인자
*/
const getComposeArgs = (args) => ['compose', '--env-file', envFile, ...args]
const getComposeArgs = (args) => [
'compose',
...(existsSync(join(rootDir, envFile)) ? ['--env-file', envFile] : []),
...args
]
/**
* psql 실행 인자 생성
@@ -234,8 +243,14 @@ const getAppliedMigrationFiles = () => {
* @returns {void}
*/
const runMigrationFile = (fileName) => {
runCommand('docker', getPsqlArgs(['-f', `${containerMigrationsDir}/${fileName}`]))
runSql(`INSERT INTO ${schemaMigrationsTable} (file_name) VALUES ('${escapeSqlLiteral(fileName)}') ON CONFLICT (file_name) DO NOTHING;`)
const recordSql = `INSERT INTO ${schemaMigrationsTable} (file_name) VALUES ('${escapeSqlLiteral(fileName)}') ON CONFLICT (file_name) DO NOTHING;`
runCommand('docker', getPsqlArgs([
'--single-transaction',
'-f',
`${containerMigrationsDir}/${fileName}`,
'-c',
recordSql
]))
}
/**

View File

@@ -0,0 +1,90 @@
import { mkdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
const rootDir = process.cwd()
const composeProjectName = 'sori-studio-playwright'
const databaseName = 'sori_studio_test'
const databasePort = process.env.TEST_DB_PORT || '43091'
const uploadRoot = join(tmpdir(), 'sori-studio-playwright-uploads')
if (!databaseName.endsWith('_test')) {
throw new Error(`테스트 DB 이름은 _test로 끝나야 합니다: ${databaseName}`)
}
export const playwrightTestEnv = {
...process.env,
PLAYWRIGHT_TEST: '1',
DATABASE_URL: `postgres://sori_studio_test:sori_studio_test_password@127.0.0.1:${databasePort}/${databaseName}`,
DATABASE_NAME: databaseName,
POSTGRES_DB: databaseName,
POSTGRES_USER: 'sori_studio_test',
POSTGRES_PASSWORD: 'sori_studio_test_password',
ADMIN_EMAIL: 'playwright-admin@example.com',
ADMIN_PASSWORD: 'playwright-admin-password',
MEMBER_SESSION_SECRET: 'playwright-member-session-secret',
ANALYTICS_HASH_SECRET: 'playwright-analytics-secret',
NUXT_PUBLIC_SITE_URL: 'http://127.0.0.1:43100',
NUXT_POST_EXPORT_WORKER: '0',
NUXT_RECOMMENDED_FAVICON_CRON: '0',
MEDIA_CATALOG_MODE: 'database',
SORI_TEST_UPLOAD_ROOT: uploadRoot,
TEST_DB_PORT: databasePort
}
const composeArgs = [
'compose',
'--project-name',
composeProjectName,
'-f',
'docker-compose.test.yml'
]
/**
* 명령을 실행하고 실패 시 즉시 중단한다.
* @param {string} command - 실행 파일
* @param {string[]} args - 실행 인자
* @returns {void}
*/
const run = (command, args) => {
const result = spawnSync(command, args, {
cwd: rootDir,
env: playwrightTestEnv,
stdio: 'inherit'
})
if (result.status !== 0) {
throw new Error(`${command} 실행 실패 (${result.status || 1})`)
}
}
/**
* Playwright 전용 DB와 업로드 디렉터리를 새로 준비한다.
* @returns {void}
*/
export const setupPlaywrightTestEnvironment = () => {
teardownPlaywrightTestEnvironment()
rmSync(uploadRoot, { recursive: true, force: true })
mkdirSync(uploadRoot, { recursive: true })
try {
run('docker', [...composeArgs, 'up', '-d', '--wait'])
} catch (error) {
teardownPlaywrightTestEnvironment()
throw error
}
}
/**
* Playwright 전용 자원만 정리한다.
* @returns {void}
*/
export const teardownPlaywrightTestEnvironment = () => {
spawnSync('docker', [...composeArgs, 'down', '--volumes'], {
cwd: rootDir,
env: playwrightTestEnv,
stdio: 'inherit'
})
rmSync(uploadRoot, { recursive: true, force: true })
}

View File

@@ -0,0 +1,40 @@
import postgres from 'postgres'
const databaseUrl = String(process.env.DATABASE_URL || '').trim()
if (!databaseUrl) {
throw new Error('DATABASE_URL_REQUIRED')
}
const parsedDatabaseUrl = new URL(databaseUrl)
const allowedHosts = new Set(['127.0.0.1', 'localhost', '::1'])
const expectedDatabase = decodeURIComponent(parsedDatabaseUrl.pathname.replace(/^\/+/, ''))
if (process.env.NODE_ENV === 'production' || !allowedHosts.has(parsedDatabaseUrl.hostname)) {
throw new Error('MEDIA_CATALOG_RECONCILE_LOCAL_DATABASE_ONLY')
}
const database = postgres(databaseUrl, {
max: 1
})
try {
const identityRows = await database`
SELECT current_database() AS database_name
`
const databaseName = identityRows[0]?.database_name
if (!databaseName || databaseName !== expectedDatabase || /prod|production/i.test(databaseName)) {
throw new Error('MEDIA_CATALOG_DATABASE_IDENTITY_MISMATCH')
}
const { reconcileMediaCatalog } = await import('../server/repositories/media-catalog-repository.js')
const report = await reconcileMediaCatalog()
console.info(JSON.stringify({
database: databaseName,
destructiveFileChanges: false,
report
}, null, 2))
} finally {
await database.end()
}

View File

@@ -0,0 +1,37 @@
import { spawnSync } from 'node:child_process'
import {
playwrightTestEnv,
setupPlaywrightTestEnvironment,
teardownPlaywrightTestEnvironment
} from './playwright-test-environment.js'
const project = process.argv[2]
if (!['api', 'e2e'].includes(project)) {
console.error('Playwright 프로젝트는 api 또는 e2e여야 합니다.')
process.exit(1)
}
let exitCode = 1
try {
setupPlaywrightTestEnvironment()
const result = spawnSync(
process.execPath,
['./node_modules/@playwright/test/cli.js', 'test', `--project=${project}`],
{
cwd: process.cwd(),
env: playwrightTestEnv,
stdio: 'inherit'
}
)
exitCode = result.status || 0
} catch (error) {
console.error(error instanceof Error ? error.message : error)
} finally {
teardownPlaywrightTestEnvironment()
}
process.exit(exitCode)