Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,25 @@ REVERSE_GEOCODING_USER_AGENT=Soundlog/0.1 (+https://github.com/SoundLogTeam/Soun
TOUR_API_BASE_URL=https://apis.data.go.kr/B551011/KorService2
TOUR_API_SERVICE_KEY=
ALLOW_DEV_AUTH_FALLBACK=false
# Rate limiting for /v1/auth/login, /v1/auth/register, /v1/auth/refresh.
# AUTH_RATE_LIMIT_ENABLED defaults to true, except under NODE_ENV=test where
# it defaults to false so test suites can call auth endpoints repeatedly.
# AUTH_RATE_LIMIT_ENABLED=true
# Per-account limit (keyed by IP + email): blocks repeated attempts against one account.
AUTH_RATE_LIMIT_WINDOW_MS=900000
AUTH_RATE_LIMIT_MAX=10
# Per-IP limit (keyed by IP only): blocks credential stuffing across many different
# emails from the same IP. Looser than the per-account limit above.
AUTH_RATE_LIMIT_IP_WINDOW_MS=900000
AUTH_RATE_LIMIT_IP_MAX=40
UPLOAD_DIRECTORY=uploads
UPLOAD_PUBLIC_BASE_URL=http://localhost:4000
UPLOAD_PUBLIC_PATH=/uploads
USE_MOCK_DB=false

# Production checklist:
# NODE_ENV=production
# CLIENT_URLS=https://soundlog.shop,https://www.soundlog.shop
# After API DNS/HTTPS reverse proxy is ready:
# UPLOAD_PUBLIC_BASE_URL=https://api.soundlog.shop
# UPLOAD_PUBLIC_PATH=/uploads
# USE_MOCK_DB=false
# ALLOW_DEV_AUTH_FALLBACK=false
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ Swagger에서 바로 DB 쓰기를 확인할 때는 인증 없이 호출 가능
- 자체 이메일/비밀번호 로그인만 사용하며, 서버는 비밀번호 원문 대신 bcrypt hash만 저장
- `CLIENT_URLS`, `UPLOAD_PUBLIC_BASE_URL`, 앱의 `EXPO_PUBLIC_SOUNDLOG_API_BASE_URL`은 HTTPS 도메인 사용
- 운영 기준 frontend origin은 `https://soundlog.shop`입니다. 공개 API URL은 `https://api.soundlog.shop`이며, GCP VM 위의 Caddy가 TLS를 직접 종료합니다.
- `REQUEST_BODY_LIMIT`, `MOMENT_PHOTO_MAX_FILE_SIZE_MB`, `UPLOAD_DIRECTORY`, `UPLOAD_PUBLIC_PATH`는 운영 파일 업로드 정책에 맞게 조정
- `REQUEST_BODY_LIMIT`, `MOMENT_PHOTO_MAX_FILE_SIZE_MB`, `UPLOAD_DIRECTORY`는 운영 파일 업로드 정책에 맞게 조정
- iOS 앱 설정에 전체 ATS 예외를 넣지 않기

서버 코드는 자체 계정 로그인(`POST /v1/auth/login`, `POST /v1/auth/register`)으로 Soundlog access/refresh token을 발급합니다.
Expand Down
1 change: 0 additions & 1 deletion docs/gcp-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ TOUR_API_BASE_URL=https://apis.data.go.kr/B551011/KorService2
TOUR_API_SERVICE_KEY=
ALLOW_DEV_AUTH_FALLBACK=false
UPLOAD_PUBLIC_BASE_URL=https://api.soundlog.shop
UPLOAD_PUBLIC_PATH=/uploads
USE_MOCK_DB=false
```

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"express-rate-limit": "^8.6.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.1",
Expand Down
33 changes: 33 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 5 additions & 7 deletions scripts/check-live-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ let primary;
let companion;

try {
await step('system health, OpenAPI, docs, and DB write', async () => {
await step('system health, OpenAPI, and docs', async () => {
const health = await request('/v1/health');
assert(health.payload?.data?.status === 'ok', 'Health status is not ok.');
assert(health.payload?.data?.database === 'ok', 'Database status is not ok.');
Expand All @@ -135,12 +135,10 @@ try {
assert(String(openApi.payload).includes('openapi: 3.1.0'), 'OpenAPI document is missing.');
await request('/docs/', { expectedStatus: 200 });

const dbRecord = await request('/v1/dev/db-test-records', {
body: { label: `live-e2e-${runId}`, payload: { source: 'check-live-e2e' } },
expectedStatus: 201,
method: 'POST',
});
assert(dbRecord.payload?.data?.id, 'DB test write did not return an id.');
// /v1/dev/db-test-records now requires auth and is unregistered in
// production, so it is no longer exercised by this unauthenticated
// smoke step. DB write behavior is still covered by the authenticated
// steps below (registration, recap captures, etc.).
});

await step('register, login, refresh, profile, and migration', async () => {
Expand Down
4 changes: 4 additions & 0 deletions scripts/check-production-env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ if (process.env.ALLOW_DEV_AUTH_FALLBACK === 'true') {
addError('ALLOW_DEV_AUTH_FALLBACK must be false or unset in production.');
}

if (process.env.AUTH_RATE_LIMIT_ENABLED === 'false') {
addError('AUTH_RATE_LIMIT_ENABLED must not be false in production.');
}

if (!isHttpsUrl(process.env.UPLOAD_PUBLIC_BASE_URL)) {
addError('UPLOAD_PUBLIC_BASE_URL must be an HTTPS URL.');
}
Expand Down
15 changes: 10 additions & 5 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,27 @@ import { errorMiddleware } from './middlewares/error.middleware.js';
import { requestLoggerMiddleware } from './middlewares/request-logger.middleware.js';
import { securityMiddleware } from './middlewares/security.middleware.js';
import { registerSwaggerDocs } from './middlewares/swagger.middleware.js';
import {
uploadedFilesPublicPath,
uploadedFilesStaticMiddleware,
} from './middlewares/upload.middleware.js';
import { createApiRouter } from './routes/index.js';
import { createUploadsRouter } from './routes/uploads.router.js';
import { notFound } from './utils/http-error.js';

export function createApp() {
const app = express();

// Behind a single Caddy reverse proxy hop (see Caddyfile / docker-compose.prod.yml).
// Trusting exactly 1 hop lets req.ip reflect the real client IP (needed for
// rate limiting) without allowing X-Forwarded-For spoofing from the client.
app.set('trust proxy', 1);

app.use(corsMiddleware);
app.use(securityMiddleware);
app.use(jsonBodyParserMiddleware);
app.use(urlencodedBodyParserMiddleware);
app.use(requestLoggerMiddleware);
app.use(uploadedFilesPublicPath, uploadedFilesStaticMiddleware);
// Uploaded photos are served only through an authenticated, ownership/visibility-checked
// endpoint (see uploads.router.ts) — there is no unauthenticated static file serving of
// the uploads directory.
app.use(createUploadsRouter());
registerSwaggerDocs(app);
app.use(createApiRouter());
app.use((_req, _res, next) => {
Expand Down
19 changes: 17 additions & 2 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ const envSchema = z.object({
.string()
.optional()
.transform((value) => value === 'true'),
AUTH_RATE_LIMIT_ENABLED: z.string().optional(),
AUTH_RATE_LIMIT_IP_MAX: z.coerce.number().int().positive().default(40),
AUTH_RATE_LIMIT_IP_WINDOW_MS: z.coerce.number().int().positive().default(15 * 60 * 1000),
AUTH_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(10),
AUTH_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(15 * 60 * 1000),
CLIENT_URL: z.string().url().default('http://localhost:8081'),
CLIENT_URLS: z.string().optional(),
DATABASE_URL: z.string().min(1),
Expand Down Expand Up @@ -37,7 +42,17 @@ const envSchema = z.object({
.transform((value) => value === 'true'),
UPLOAD_DIRECTORY: z.string().min(1).default('uploads'),
UPLOAD_PUBLIC_BASE_URL: z.string().url().default('http://localhost:4000'),
UPLOAD_PUBLIC_PATH: z.string().min(1).default('/uploads'),
});

export const env = envSchema.parse(process.env);
const parsedEnv = envSchema.parse(process.env);

export const env = {
...parsedEnv,
// Defaults to disabled under NODE_ENV=test so existing tests that call
// auth endpoints repeatedly are not destabilized. Set
// AUTH_RATE_LIMIT_ENABLED=true explicitly to exercise the limiter in tests.
AUTH_RATE_LIMIT_ENABLED:
parsedEnv.AUTH_RATE_LIMIT_ENABLED === undefined
? parsedEnv.NODE_ENV !== 'test'
: parsedEnv.AUTH_RATE_LIMIT_ENABLED === 'true',
};
37 changes: 37 additions & 0 deletions src/controllers/upload-file.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Request, Response } from 'express';

import { requireUser } from '../middlewares/auth.middleware.js';
import { uploadFileService } from '../services/upload-file.service.js';
import { notFound } from '../utils/http-error.js';

export const uploadFileController = {
async getUploadedFile(req: Request, res: Response) {
const user = requireUser(req);
const fileId = String(req.params.fileId);

const resolved = await uploadFileService.resolveUploadedFileForUser(user.id, fileId);

// Unknown file, disallowed access, and invalid/traversal file ids all resolve the
// same way (undefined) and all produce the same 404, so a caller cannot use the
// response to tell a private file that doesn't belong to them apart from a file
// that simply doesn't exist.
if (!resolved) {
throw notFound();
}

// multer stores uploads with no extension, so Content-Type must be derived from the
// file's actual bytes (never the client-supplied upload MIME type or a filename), or
// helmet's `X-Content-Type-Options: nosniff` leaves browsers refusing to render it. If
// the bytes don't match a known image signature, the file is not served as an image at
// all — this also covers legacy/unexpected on-disk files that happen to have a matching
// DB row but aren't actually images.
const contentType = await uploadFileService.detectImageContentType(resolved.absolutePath);

if (!contentType) {
throw notFound();
}

res.type(contentType);
res.sendFile(resolved.absolutePath);
},
};
Loading
Loading