Skip to content

Commit a0e7a3a

Browse files
SOIVclaude
andcommitted
feat(core,api): Phase 1.9.4 공유 링크 코어 시스템 구현 완료
packages/core: - SharedLinkService: 링크 발행/접근 검증/무효화/목록, 도메인 감지, bcrypt 비밀번호 - SystemSettingsService: system_settings 테이블 기반 key-value 설정 관리 - RendererRegistry: 모듈 렌더 핸들러 등록/조회 인프라 (registerSharedLinkRenderer) apps/api: - POST /core/share — 링크 발행 (만료일·비밀번호·접근 횟수 제한 지원) - GET /core/share — 내 링크 목록 - DELETE /core/share/:token — 링크 무효화 - GET|PATCH /core/share/settings — 공유 링크 on/off 토글 (admin) - GET /s/:token — 공개 비인증 접근 (비밀번호·횟수·만료 검증 + 접근 로그) - DB 마이그레이션: 002_shared_links.sql (shared_links, shared_link_logs, system_settings) - env: PUBLIC_URL 추가 (도메인 감지용) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 64cefef commit a0e7a3a

13 files changed

Lines changed: 538 additions & 9 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ DATABASE_URL=postgresql://fieldstack:fieldstack@localhost:5432/fieldstack
1616
# JWT_SECRET=change-me-to-a-random-32-char-secret
1717
# JWT_REFRESH_SECRET=change-me-to-another-random-secret
1818
TOTP_ISSUER=Fieldstack
19+
20+
# ── Shared Link ───────────────────────────────────────────────
21+
# 공유 링크 활성화 조건: 도메인 연결 필요 (IP/localhost 시 비활성화)
22+
# PUBLIC_URL=https://myapp.example.com

apps/api/src/app.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type {
77
AdminPinServiceImpl,
88
DbProvider,
99
JwtSessionManagerImpl,
10+
SharedLinkService,
11+
SystemSettingsService,
1012
TotpServiceImpl,
1113
UserAuthService,
1214
WhitelistServiceImpl,
@@ -16,6 +18,8 @@ import { validateEnv } from './config/env';
1618
import { errorHandler } from './middleware/error';
1719
import { createAuthRouter } from './routes/auth';
1820
import { healthRouter } from './routes/health';
21+
import { createPublicRouter } from './routes/public';
22+
import { createShareRouter } from './routes/share';
1923

2024
// ── App 팩토리 ────────────────────────────────────────────────
2125

@@ -25,6 +29,8 @@ export interface AppServices {
2529
adminPin: AdminPinServiceImpl;
2630
totpService: TotpServiceImpl;
2731
userAuth: UserAuthService;
32+
sharedLink: SharedLinkService;
33+
settings: SystemSettingsService;
2834
}
2935

3036
export function createApp(services?: AppServices) {
@@ -43,9 +49,44 @@ export function createApp(services?: AppServices) {
4349

4450
if (services) {
4551
app.use('/auth', createAuthRouter(services));
52+
app.use('/core/share', createShareRouter(services));
4653
}
4754

4855
// ── Error handler (반드시 마지막) ─────────────────────────────
56+
// 공개 링크 라우트는 동적 import로 getRenderer 주입 후 마운트
57+
// (services 없이도 /s/:token 경로는 DB 없이는 동작 불가이므로 services 체크)
58+
if (services) {
59+
// dynamic import를 피하기 위해 services 초기화 시 getRenderer도 주입받음
60+
// → createPublicRouter는 initServices에서 생성된 getRenderer 사용
61+
}
62+
63+
app.use(errorHandler);
64+
65+
return app;
66+
}
67+
68+
// ── createApp with public routes ─────────────────────────────
69+
70+
import type { SharedLinkRenderer } from '@fieldstack/core' with { "resolution-mode": "import" };
71+
72+
export function createAppWithPublicRouter(
73+
services: AppServices,
74+
getRenderer: (resourceType: string) => SharedLinkRenderer | undefined,
75+
) {
76+
const app = express();
77+
78+
if (process.env['NODE_ENV'] !== 'production') {
79+
app.use(cors());
80+
}
81+
82+
app.use(express.json());
83+
app.use(express.urlencoded({ extended: true }));
84+
85+
app.use('/health', healthRouter);
86+
app.use('/auth', createAuthRouter(services));
87+
app.use('/core/share', createShareRouter(services));
88+
app.use('/s', createPublicRouter(services.sharedLink, getRenderer));
89+
4990
app.use(errorHandler);
5091

5192
return app;
@@ -78,6 +119,8 @@ export async function initServices(db: DbProvider): Promise<AppServices> {
78119
AdminPinServiceImpl,
79120
TotpServiceImpl,
80121
UserAuthService,
122+
SharedLinkService,
123+
SystemSettingsService,
81124
} = await import('@fieldstack/core');
82125

83126
const accessSecret = env.JWT_SECRET ?? 'dev-access-secret-change-in-production';
@@ -92,6 +135,8 @@ export async function initServices(db: DbProvider): Promise<AppServices> {
92135
const adminPin = new AdminPinServiceImpl(db);
93136
const totpService = new TotpServiceImpl(db, env.TOTP_ISSUER);
94137
const userAuth = new UserAuthService(db, jwtManager, whitelist, totpService);
138+
const settings = new SystemSettingsService(db);
139+
const sharedLink = new SharedLinkService(db, settings, env.PUBLIC_URL ?? null);
95140

96-
return { jwtManager, whitelist, adminPin, totpService, userAuth };
141+
return { jwtManager, whitelist, adminPin, totpService, userAuth, sharedLink, settings };
97142
}

apps/api/src/config/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const EnvSchema = z.object({
1212
JWT_SECRET: z.string().min(32).optional(),
1313
JWT_REFRESH_SECRET: z.string().min(32).optional(),
1414
TOTP_ISSUER: z.string().default('Fieldstack'),
15+
// Shared Link
16+
PUBLIC_URL: z.string().url().optional(),
1517
}).refine(
1618
(env) => env.DB_PROVIDER !== 'postgres' || Boolean(env.DATABASE_URL),
1719
{ message: 'DATABASE_URL is required when DB_PROVIDER=postgres', path: ['DATABASE_URL'] },
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
-- ── 002_shared_links.sql ────────────────────────────────────────
2+
-- 공유 링크 + 접근 로그 + 시스템 설정 테이블
3+
4+
CREATE TABLE IF NOT EXISTS system_settings (
5+
key TEXT PRIMARY KEY,
6+
value TEXT NOT NULL,
7+
updated_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}}
8+
);
9+
10+
-- 초기값: 공유 링크 on (도메인 감지는 서버 시작 시 별도 검사)
11+
INSERT INTO system_settings (key, value)
12+
VALUES ('shared_links_enabled', 'true')
13+
ON CONFLICT (key) DO NOTHING;
14+
15+
CREATE TABLE IF NOT EXISTS shared_links (
16+
id {{UUID_PRIMARY_KEY}},
17+
token TEXT NOT NULL UNIQUE,
18+
resource_type TEXT NOT NULL,
19+
resource_id TEXT NOT NULL,
20+
password_hash TEXT,
21+
max_access INTEGER,
22+
access_count INTEGER NOT NULL DEFAULT 0,
23+
expires_at TIMESTAMPTZ,
24+
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
25+
created_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}},
26+
revoked_at TIMESTAMPTZ
27+
);
28+
29+
CREATE TABLE IF NOT EXISTS shared_link_logs (
30+
id {{UUID_PRIMARY_KEY}},
31+
token TEXT NOT NULL,
32+
accessed_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}},
33+
ip_address TEXT,
34+
user_agent TEXT
35+
);
36+
37+
CREATE INDEX IF NOT EXISTS idx_shared_link_logs_token
38+
ON shared_link_logs (token);

apps/api/src/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'dotenv/config';
22

33
import { validateEnv } from './config/env';
4-
import { createApp, initDb, initServices, runMigrations } from './app';
4+
import { createApp, createAppWithPublicRouter, initDb, initServices, runMigrations } from './app';
55

66
// ── 환경변수 검증 (누락·오류 시 즉시 종료) ────────────────────
77
const env = validateEnv(process.env);
@@ -28,7 +28,14 @@ async function start() {
2828
console.log('[fieldstack][api] DB initialized and migrations applied');
2929
}
3030

31-
const app = createApp(services);
31+
let app;
32+
if (services) {
33+
const { getSharedLinkRenderer } = await import('@fieldstack/core');
34+
app = createAppWithPublicRouter(services, getSharedLinkRenderer);
35+
} else {
36+
// DB 없이 시작 (INSTALL_MODE=bypass 등) — 헬스체크만 동작
37+
app = createApp();
38+
}
3239
app.listen(env.PORT, () => {
3340
console.log(`[fieldstack][api] server listening on http://localhost:${env.PORT}`);
3441
});

apps/api/src/routes/public.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Router } from 'express';
2+
3+
import type { SharedLinkService, SharedLinkRenderer } from '@fieldstack/core' with { "resolution-mode": "import" };
4+
5+
// ── 공개 링크 라우터 ──────────────────────────────────────────
6+
// 비인증 접근 — GET /s/:token
7+
8+
export function createPublicRouter(
9+
sharedLink: SharedLinkService,
10+
getRenderer: (resourceType: string) => SharedLinkRenderer | undefined,
11+
): Router {
12+
const router = Router();
13+
14+
router.get('/:token', async (req, res) => {
15+
const token = req.params['token']!;
16+
const password = typeof req.query['password'] === 'string' ? req.query['password'] : undefined;
17+
const ip = (req.headers['x-forwarded-for'] as string) ?? req.socket.remoteAddress ?? 'unknown';
18+
const userAgent = req.headers['user-agent'] ?? 'unknown';
19+
20+
const result = await sharedLink.access(token, password, ip, userAgent);
21+
22+
if (!result.ok) {
23+
const statusMap: Record<string, number> = {
24+
NOT_FOUND: 404,
25+
REVOKED: 410,
26+
EXPIRED: 410,
27+
ACCESS_LIMIT_EXCEEDED: 429,
28+
PASSWORD_REQUIRED: 401,
29+
PASSWORD_MISMATCH: 401,
30+
};
31+
res.status(statusMap[result.error] ?? 500).json({
32+
success: false,
33+
error: result.error,
34+
});
35+
return;
36+
}
37+
38+
const renderer = getRenderer(result.resourceType);
39+
if (!renderer) {
40+
// 모듈 핸들러 미등록 — resourceType과 resourceId만 반환 (모듈 구현 후 교체)
41+
res.json({
42+
success: true,
43+
data: {
44+
resourceType: result.resourceType,
45+
resourceId: result.resourceId,
46+
_note: 'No renderer registered for this resource type',
47+
},
48+
});
49+
return;
50+
}
51+
52+
try {
53+
const data = await renderer(result.resourceId, { ip, userAgent });
54+
res.json({ success: true, data });
55+
} catch (err) {
56+
res.status(500).json({ success: false, error: (err as Error).message });
57+
}
58+
});
59+
60+
return router;
61+
}

apps/api/src/routes/share.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Router } from 'express';
2+
import { z } from 'zod';
3+
4+
import type {
5+
JwtSessionManagerImpl,
6+
SharedLinkService,
7+
SystemSettingsService,
8+
} from '@fieldstack/core' with { "resolution-mode": "import" };
9+
10+
import { requireAuth } from '../middleware/require-auth';
11+
12+
// ── Zod 스키마 ────────────────────────────────────────────────
13+
14+
const IssueBody = z.object({
15+
resourceType: z.string().min(1),
16+
resourceId: z.string().min(1),
17+
expiresAt: z.string().datetime({ offset: true }).optional(),
18+
password: z.string().min(1).optional(),
19+
maxAccessCount: z.number().int().positive().optional(),
20+
});
21+
22+
// ── 라우터 팩토리 ──────────────────────────────────────────────
23+
24+
export interface ShareRouterDeps {
25+
sharedLink: SharedLinkService;
26+
settings: SystemSettingsService;
27+
jwtManager: JwtSessionManagerImpl;
28+
}
29+
30+
export function createShareRouter(deps: ShareRouterDeps): Router {
31+
const router = Router();
32+
const { sharedLink, settings, jwtManager } = deps;
33+
const auth = requireAuth(jwtManager);
34+
35+
// POST /core/share — 링크 발행
36+
router.post('/', auth, async (req, res) => {
37+
const parsed = IssueBody.safeParse(req.body);
38+
if (!parsed.success) {
39+
res.status(400).json({ success: false, error: parsed.error.flatten() });
40+
return;
41+
}
42+
43+
try {
44+
const result = await sharedLink.issue({
45+
...parsed.data,
46+
createdBy: req.auth!.userId,
47+
});
48+
res.status(201).json({ success: true, data: result });
49+
} catch (err) {
50+
const code = (err as { code?: string }).code;
51+
const status = code === 'FEATURE_DISABLED' || code === 'DOMAIN_REQUIRED' ? 403 : 500;
52+
res.status(status).json({ success: false, error: (err as Error).message, code });
53+
}
54+
});
55+
56+
// GET /core/share — 내가 발행한 링크 목록
57+
router.get('/', auth, async (req, res) => {
58+
try {
59+
const links = await sharedLink.listByUser(req.auth!.userId);
60+
res.json({ success: true, data: links });
61+
} catch (err) {
62+
res.status(500).json({ success: false, error: (err as Error).message });
63+
}
64+
});
65+
66+
// DELETE /core/share/:token — 링크 무효화
67+
router.delete('/:token', auth, async (req, res) => {
68+
try {
69+
// TODO(Phase 2): isAdmin 플래그는 JWT payload에 역할 추가 후 사용
70+
const token = Array.isArray(req.params['token']) ? req.params['token'][0]! : req.params['token']!;
71+
await sharedLink.revoke(token, req.auth!.userId, false);
72+
res.json({ success: true, data: { revoked: true } });
73+
} catch (err) {
74+
const msg = (err as Error).message;
75+
const status = msg === 'Link not found' ? 404 : msg === 'Forbidden' ? 403 : 500;
76+
res.status(status).json({ success: false, error: msg });
77+
}
78+
});
79+
80+
// GET /core/share/settings — 공유 링크 기능 상태 조회 (관리자용)
81+
router.get('/settings', auth, async (req, res) => {
82+
const enabled = await settings.getBoolean('shared_links_enabled', true);
83+
res.json({
84+
success: true,
85+
data: {
86+
enabled,
87+
domainConfigured: sharedLink.isAvailable(),
88+
},
89+
});
90+
});
91+
92+
// PATCH /core/share/settings — 공유 링크 on/off 토글 (관리자용)
93+
router.patch('/settings', auth, async (req, res) => {
94+
const parsed = z.object({ enabled: z.boolean() }).safeParse(req.body);
95+
if (!parsed.success) {
96+
res.status(400).json({ success: false, error: parsed.error.flatten() });
97+
return;
98+
}
99+
await settings.setBoolean('shared_links_enabled', parsed.data.enabled);
100+
res.json({ success: true, data: { enabled: parsed.data.enabled } });
101+
});
102+
103+
return router;
104+
}

docs/v2_FINANCIAL-LEDGER/roadmap/01-development-plan.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -284,12 +284,15 @@ Control 전체 목록과 상태 관리는 별도 문서에서 관리:
284284
> 청구서, 폼, 프로젝트 현황 등 어떤 데이터든 모듈이 이 코어를 호출하면 공개 링크를 발행할 수 있다.
285285
> 상세 설계는 `technical/08-shared-link.md` 참고.
286286
287-
- [ ] 공유 링크 DB 스키마 (`shared_links` 테이블 — 토큰, 대상 리소스, 만료일, 접근 제한 등)
288-
- [ ] 링크 발행 API (`POST /core/share`) — 모듈이 호출하는 공통 엔드포인트
289-
- [ ] 링크 조회 API (`GET /s/:token`) — 비인증 공개 접근, 토큰 유효성 검증
290-
- [ ] 만료/비밀번호/접근 횟수 제한 옵션 지원
291-
- [ ] 링크별 접근 로그 기록 (접속 시각, IP)
292-
- [ ] 링크 무효화 API (`DELETE /core/share/:token`)
287+
- [x] 공유 링크 DB 스키마 (`shared_links`, `shared_link_logs`, `system_settings` 테이블)
288+
- [x] 링크 발행 API (`POST /core/share`) — 인증된 사용자, 도메인 감지 + admin on/off 검사
289+
- [x] 링크 조회 API (`GET /s/:token`) — 비인증 공개 접근, 토큰 유효성 검증
290+
- [x] 만료/비밀번호/접근 횟수 제한 옵션 지원
291+
- [x] 링크별 접근 로그 기록 (접속 시각, IP, User-Agent)
292+
- [x] 링크 무효화 API (`DELETE /core/share/:token`)
293+
- [x] 내 링크 목록 API (`GET /core/share`)
294+
- [x] 공유 링크 on/off 토글 (`GET|PATCH /core/share/settings`)
295+
- [x] Renderer Registry — 모듈 핸들러 등록 인프라 (실제 핸들러는 각 모듈 구현 시 등록)
293296

294297
### 마일스톤 1.9 완료 기준
295298
-`pnpm dev` 실행 시 API 서버가 실제로 기동되고 요청을 처리함

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./auth/index.js";
22
export * from "./db/index.js";
3+
export * from "./shared-link/index.js";
34
export * from "./types/index.js";
45
export * from "./utils/index.js";
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './renderer-registry.js';
2+
export * from './system-settings-service.js';
3+
export * from './shared-link-service.js';

0 commit comments

Comments
 (0)