From 464715aa69624e58e9104c8e546e0d28317e7e69 Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 12 Oct 2026 15:54:26 +0900 Subject: [PATCH] Add resumable uploads, proxy discovery, PWA install and 2 GiB personal vaults --- .dockerignore | 1 + .env.example | 4 +- .gitignore | 1 + README.md | 8 +- apps/server/src/accounts.ts | 34 ++- apps/server/src/app.ts | 80 +++++- apps/server/src/check-proxy.js | 18 ++ apps/server/src/config.ts | 10 +- apps/server/src/resumable.js | 252 ++++++++++++++++++ apps/server/src/security.ts | 8 + apps/server/src/service.ts | 3 + apps/server/src/storage.ts | 60 ++++- apps/server/src/vault.js | 12 + apps/server/test/cloudflare-discovery.test.js | 74 +++++ apps/server/test/phase3.test.ts | 6 +- apps/server/test/resumable.test.js | 182 +++++++++++++ apps/server/test/vault.test.js | 90 +++++++ apps/web/index.html | 4 + apps/web/public/icon-192.png | Bin 0 -> 3675 bytes apps/web/public/icon-512.png | Bin 0 -> 14234 bytes apps/web/public/icon-maskable.png | Bin 0 -> 10134 bytes apps/web/public/manifest.webmanifest | 17 ++ apps/web/public/sw.js | 18 ++ apps/web/src/AccountPanel.vue | 83 ++++-- apps/web/src/App.vue | 37 ++- apps/web/src/InstallButton.vue | 94 +++++++ apps/web/src/SendComposer.vue | 38 ++- apps/web/src/client.js | 206 ++++++++++---- apps/web/src/main.js | 5 + apps/web/src/style.css | 40 +++ compose.yaml | 14 +- deploy.md | 104 +++++++- docs/TEMPORARY_VAULT.md | 8 + docs/VERIFICATION.md | 9 + package.json | 4 +- scripts/configure-nas.sh | 16 ++ scripts/enable-nas-discovery.sh | 23 ++ scripts/print-hop-proxy-config.sh | 14 + tests/docker/persistence.mjs | 8 +- tests/e2e/chunks.mjs | 106 ++++++++ tests/e2e/phase1.mjs | 5 +- tests/e2e/phase3.mjs | 2 +- tests/e2e/pwa.mjs | 86 ++++++ 43 files changed, 1647 insertions(+), 137 deletions(-) create mode 100644 apps/server/src/check-proxy.js create mode 100644 apps/server/src/resumable.js create mode 100644 apps/server/test/cloudflare-discovery.test.js create mode 100644 apps/server/test/resumable.test.js create mode 100644 apps/web/public/icon-192.png create mode 100644 apps/web/public/icon-512.png create mode 100644 apps/web/public/icon-maskable.png create mode 100644 apps/web/public/manifest.webmanifest create mode 100644 apps/web/public/sw.js create mode 100644 apps/web/src/InstallButton.vue create mode 100644 scripts/enable-nas-discovery.sh create mode 100644 scripts/print-hop-proxy-config.sh create mode 100644 tests/e2e/chunks.mjs create mode 100644 tests/e2e/pwa.mjs diff --git a/.dockerignore b/.dockerignore index 72b8499..c59040a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,3 +11,4 @@ playwright-report tests/e2e/*.png compose.nas.yaml backups +hop.proxy.conf diff --git a/.env.example b/.env.example index a2d1b38..745b697 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,8 @@ UPLOAD_DIR=/data/uploads # 실제 접속 IP 전달을 검증한 후에만 활성화합니다. SAME_NETWORK_DISCOVERY_ENABLED=false TRUSTED_PROXY_CIDRS= +CLIENT_IP_SOURCE=forwarded +PROXY_DIAGNOSTIC_TOKEN= GUEST_SESSION_RETENTION_DAYS=30 LOCAL_REQUEST_TIMEOUT_SECONDS=60 ACCEPTED_UPLOAD_START_HOURS=24 @@ -33,7 +35,7 @@ RESEND_API_KEY= MAIL_FROM=hop. SETUP_TOKEN= MAX_FILE_SIZE_MB=2048 -USER_STORAGE_LIMIT_MB=10240 +USER_STORAGE_LIMIT_MB=2048 GUEST_STORAGE_LIMIT_MB=2048 TOTAL_GUEST_STORAGE_LIMIT_MB=20480 MIN_FREE_DISK_MB=5120 diff --git a/.gitignore b/.gitignore index df6a427..cdfd0ba 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ test-results/ tests/e2e/*.png compose.nas.yaml backups/ +hop.proxy.conf diff --git a/README.md b/README.md index c5719fd..edd6813 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ QR·8자리 코드는 기본 10분의 임시 공간 초대입니다. 최대 10 업데이트 전 앱을 정지하고 DB 볼륨(관련 WAL·SHM 포함)과 업로드 볼륨을 같은 시점에 백업합니다. SQLite 파일만 실행 중에 임의 복사하지 마세요. 복원은 별도 볼륨에서 DB integrity check와 실제 자료 접근을 확인한 뒤 적용합니다. 실제 Docker 백업·복원 검증은 Phase 5에서 수행합니다. -파일당 기본 2GiB, 익명 주체당 2GiB, 전체 익명 20GiB 제한과 최소 5GiB 여유 공간을 적용합니다. MB/KB 환경변수는 MiB/KiB 기준입니다. 파일은 수락 후 스트리밍 저장하고 중단 시 임시 파일·예약을 회수합니다. 초기에는 업로드 재개나 백그라운드 완료를 보장하지 않습니다. +파일당 기본 2GiB, 익명 주체당 2GiB, 전체 익명 20GiB 제한과 최소 5GiB 여유 공간을 적용합니다. MB/KB 환경변수는 MiB/KiB 기준입니다. 파일은 수락 후 스트리밍 저장하고 중단 시 임시 파일·예약을 회수합니다. 파일은 8MiB 단위로 나누어 전송하고 서버가 받은 위치를 기록합니다. 중간 오류는 최대 3회 자동 재시도하며, 현재 화면에서 같은 파일로 ‘다시 전송’할 수 있습니다. 백그라운드 완료는 보장하지 않습니다. ## 개발 환경의 QR @@ -90,3 +90,9 @@ QR·8자리 코드는 기본 10분의 임시 공간 초대입니다. 최대 10 개발 실행은 같은 PC의 다른 브라우저를 자동 발견하고 반복 검증용 요청 한도를 사용합니다. QR 방에 들어가면 방 안의 기기만 보이며 한 방만 참여할 수 있습니다. 방장 나가기는 방 전체를 닫습니다. 운영 발견은 실제 프록시 경로를 검증한 후 켭니다. `npm run test:e2e:layout`은 실제 12개 기기 연결과 4/3/2열, 선택 전 입력 없음, 중앙 모달/하단 시트 및 방 격리를 검증합니다. + +## NAS 업데이트·PWA·개인 용량 + +이번 NAS 업데이트는 [deploy.md](deploy.md)의 7단계부터 진행합니다. 운영 자동 발견은 실제 Tunnel → NPM → hop. 경로를 진단해 인접 프록시만 신뢰하며, Cloudflare의 원본 IP로 같은 네트워크 기기를 구분합니다. 공개 주소는 HTTPS를 사용합니다. 모바일 상단 설치 버튼으로 PWA 설치 또는 iPhone 홈 화면 추가를 안내합니다. 파일·계정 기록·API 응답은 오프라인 캐시에 넣지 않습니다. + +로그인 계정의 임시 저장과 받은 파일을 합쳐 개인별 2GiB로 제한합니다. 같은 원본은 한 계정에서 한 번 계산하고 다른 계정의 한도와 분리합니다. 한도가 차면 파일 수신·다운로드는 계속하고 받은 항목에 저장하지 않았다는 안내를 표시합니다. 익명 업로드는 기기 세션당 2GiB, 서버 전체 20GiB이며 화면에는 내역을 표시하지 않습니다. diff --git a/apps/server/src/accounts.ts b/apps/server/src/accounts.ts index e61a2f7..9a5fb25 100644 --- a/apps/server/src/accounts.ts +++ b/apps/server/src/accounts.ts @@ -515,15 +515,8 @@ export class Accounts { : null, }; } - used(userId: string) { - const db = this.service.db; - const owned = db - .select() - .from(s.files) - .where(and(eq(s.files.ownerUserId, userId), inArray(s.files.status, ['stored', 'cleaning']))) - .all(); - const accounted = new Map(owned.map((f) => [f.id, f.size])); - const preserved = db + vaultFiles(userId: string) { + const rows = this.service.db .select({ file: s.files }) .from(s.access) .innerJoin(s.items, eq(s.access.itemId, s.items.id)) @@ -531,14 +524,31 @@ export class Accounts { .where( and( eq(s.access.userId, userId), - or(eq(s.access.favorite, true), eq(s.access.role, 'self')), + inArray(s.access.role, ['self', 'recipient']), isNull(s.access.deletedAt), gt(s.items.expiresAt, Date.now()), + eq(s.files.status, 'stored'), ), ) .all(); - for (const { file } of preserved) accounted.set(file.id, file.size); - return [...accounted.values()].reduce((a, b) => a + b, 0); + return new Map(rows.map(({ file }) => [file.id, file.size])); + } + used(userId: string) { + return [...this.vaultFiles(userId).values()].reduce((sum, bytes) => sum + bytes, 0); + } + canStoreFile(userId: string, file: typeof s.files.$inferSelect) { + const files = this.vaultFiles(userId); + if (files.has(file.id)) return true; + const reserved = this.service.db + .select() + .from(s.reservations) + .where(and(eq(s.reservations.userId, userId), gt(s.reservations.expiresAt, Date.now()))) + .all() + .reduce((sum, row) => sum + row.bytes, 0); + return ( + [...files.values()].reduce((sum, bytes) => sum + bytes, 0) + reserved + file.size <= + this.service.cfg.userQuota + ); } ensureQuota(userId: string, bytes: number) { if (this.used(userId) + bytes > this.service.cfg.userQuota) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index d0fb8ca..9a5ad64 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -30,7 +30,8 @@ import type { Config } from './config.js'; import { openDatabase } from './db/index.js'; import { Service, type Guest } from './service.js'; import { Presence } from './presence.js'; -import { ApiError, fail, clientIp } from './security.js'; +import { ApiError, fail, clientIp, normalizeIp, publicIp, hash } from './security.js'; +import { CHUNK_BYTES } from './resumable.js'; export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) { const app = Fastify({ @@ -49,6 +50,9 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) await app.register(multipart, { limits: { fileSize: cfg.maxFileBytes + 1, files: 1, fields: 0, parts: 1 }, }); + app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => + done(null, body), + ); const { db, sqlite } = openDatabase(cfg.databasePath), service = new Service(db, cfg); let devOrigin: string | undefined; @@ -58,7 +62,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) (origin === new URL(cfg.appUrl).origin || (!cfg.production && origin === devOrigin)), ); const ip = (req: FastifyRequest) => - clientIp(req.raw.socket.remoteAddress, req.headers, cfg.trustedProxies); + clientIp(req.raw.socket.remoteAddress, req.headers, cfg.trustedProxies, cfg.clientIpSource); const io = new Server(app.server, { serveClient: false, maxHttpBufferSize: 16384, @@ -93,6 +97,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) socket.request.socket.remoteAddress, socket.request.headers, cfg.trustedProxies, + cfg.clientIpSource, ); next(); } catch { @@ -164,10 +169,19 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) ); if (req.url.startsWith('/api/')) { reply.header('Cache-Control', 'no-store'); - service.limiter.hit(`api-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, 240); + const resumable = req.url.split('?')[0].startsWith('/api/uploads/resumable'); + service.limiter.hit( + `${resumable ? 'chunk' : 'api'}-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, + resumable ? 6000 : 240, + ); + if (resumable) service.limiter.hit(`chunk-guest:${auth(req).id}`, 1200); if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { if (!allowed(req.headers.origin)) - fail(403, 'ORIGIN_INVALID', '허용되지 않은 접속 경로입니다.'); + fail( + 403, + 'ORIGIN_INVALID', + `서비스 주소 ${cfg.appUrl}에서 접속해 주세요. 현재 접속 주소가 서버 설정과 다릅니다.`, + ); if (req.url.split('?')[0] !== '/api/guest/session') { auth(req); csrf(req); @@ -209,6 +223,20 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) expires: new Date(expiresAt), }); app.get('/api/health', async () => ({ status: 'ok', phase: 1 })); + app.get('/api/ops/proxy-diagnostic', async (req) => { + if (!cfg.proxyDiagnosticToken) fail(404, 'NOT_FOUND', '없는 경로입니다.'); + const given = req.headers.authorization?.replace(/^Bearer /, '') ?? ''; + if (!timingSafeEqual(Buffer.from(hash(given)), Buffer.from(hash(cfg.proxyDiagnosticToken!)))) + fail(403, 'OPERATOR_REQUIRED', '운영자 확인이 필요합니다.'); + const raw = req.headers['cf-connecting-ip']; + const candidate = typeof raw === 'string' ? normalizeIp(raw.trim()) : null; + return { + peerIp: normalizeIp(req.raw.socket.remoteAddress ?? ''), + cloudflareClientIpPresent: publicIp(candidate), + source: cfg.clientIpSource, + resolved: Boolean(ip(req)), + }; + }); app.post('/api/guest/session', async (req, reply) => { service.limiter.hit(`session-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, 20); let token = req.cookies.hop_guest, @@ -409,6 +437,50 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) readAccess(accessSubject(req, id(req)), id(req), body.read); return { ok: true }; }); + const resumableSubject = (req: FastifyRequest) => { + const file = db + .select() + .from(tables.files) + .where(eq(tables.files.id, id(req))) + .get(); + if (!file) fail(404, 'UPLOAD_NOT_FOUND', '업로드를 찾을 수 없습니다.'); + return requestSubject(req, file!.requestId); + }; + app.post('/api/uploads/resumable', async (req) => { + const input = z + .object({ + requestId: z.uuid(), + fileIndex: z + .number() + .int() + .min(0) + .max(cfg.maxItems - 1), + fingerprint: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + .parse(req.body); + return service.resumable.start( + requestSubject(req, input.requestId), + input.requestId, + input.fileIndex, + input.fingerprint, + ); + }); + app.get('/api/uploads/resumable/:id', async (req) => + service.resumable.status(resumableSubject(req), id(req)), + ); + app.put('/api/uploads/resumable/:id', { bodyLimit: CHUNK_BYTES }, async (req) => { + const offset = z.coerce + .number() + .int() + .min(0) + .max(cfg.maxFileBytes) + .parse(req.headers['upload-offset']); + return service.resumable.append(resumableSubject(req), id(req), offset, req.body); + }); + app.post('/api/uploads/resumable/:id/complete', async (req) => + service.resumable.complete(resumableSubject(req), id(req), req), + ); app.post('/api/uploads', async (req) => { const input = z .object({ diff --git a/apps/server/src/check-proxy.js b/apps/server/src/check-proxy.js new file mode 100644 index 0000000..9d1b132 --- /dev/null +++ b/apps/server/src/check-proxy.js @@ -0,0 +1,18 @@ +import { normalizeIp } from './security.js'; +const url = new URL('/api/ops/proxy-diagnostic', process.env.APP_URL); +const token = process.env.PROXY_DIAGNOSTIC_TOKEN; +if (!token) throw Error('먼저 configure-nas.sh를 실행하고 새 설정으로 재배포하세요.'); +const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + redirect: 'error', + signal: AbortSignal.timeout(15000), +}); +if (!response.ok) throw Error(`공개 서비스 주소를 통한 프록시 확인 실패: ${response.status}`); +const result = await response.json(); +if (!result.cloudflareClientIpPresent || !normalizeIp(result.peerIp ?? '')) + throw Error( + 'CF-Connecting-IP와 실제 인접 프록시 IP가 확인되지 않았습니다. NPM/Tunnel 설정을 확인하세요.', + ); +console.log(`TRUSTED_PROXY_CIDRS=${result.peerIp}`); +console.log('CLIENT_IP_SOURCE=cloudflare'); +console.log('SAME_NETWORK_DISCOVERY_ENABLED=true'); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 3cdc1fb..a941c82 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -8,6 +8,8 @@ export type Config = { discoverySecret: string; discoveryEnabled: boolean; trustedProxies: string[]; + clientIpSource: 'forwarded' | 'cloudflare'; + proxyDiagnosticToken?: string; port: number; host: string; guestDays: number; @@ -57,6 +59,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { const appUrl = env.APP_URL ?? 'http://127.0.0.1'; if (production && !appUrl.startsWith('https://')) throw Error('Production APP_URL must use HTTPS'); + if (env.CLIENT_IP_SOURCE && !['forwarded', 'cloudflare'].includes(env.CLIENT_IP_SOURCE)) + throw Error('Invalid CLIENT_IP_SOURCE'); + if (env.PROXY_DIAGNOSTIC_TOKEN && env.PROXY_DIAGNOSTIC_TOKEN.length < 32) + throw Error('PROXY_DIAGNOSTIC_TOKEN must contain at least 32 characters'); return { production, appUrl, @@ -72,6 +78,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { .split(',') .map((v) => v.trim()) .filter(Boolean), + clientIpSource: env.CLIENT_IP_SOURCE === 'cloudflare' ? 'cloudflare' : 'forwarded', + proxyDiagnosticToken: env.PROXY_DIAGNOSTIC_TOKEN || undefined, port, host: env.HOST ?? '127.0.0.1', guestDays: num('GUEST_SESSION_RETENTION_DAYS', 30), @@ -99,7 +107,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { setupToken: env.SETUP_TOKEN, resendApiKey: env.RESEND_API_KEY?.trim() || undefined, mailFrom: env.MAIL_FROM ?? 'hop. ', - userQuota: num('USER_STORAGE_LIMIT_MB', 10240) * 1048576, + userQuota: num('USER_STORAGE_LIMIT_MB', 2048) * 1048576, emailRequestDays: num('EMAIL_REQUEST_RETENTION_DAYS', 7), webRoot: env.WEB_ROOT, }; diff --git a/apps/server/src/resumable.js b/apps/server/src/resumable.js new file mode 100644 index 0000000..83cca83 --- /dev/null +++ b/apps/server/src/resumable.js @@ -0,0 +1,252 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { + mkdirSync, + readFileSync, + writeFileSync, + renameSync, + rmSync, + readdirSync, + existsSync, + statSync, +} from 'node:fs'; +import { open, stat, truncate } from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import { resolve } from 'node:path'; +import { eq } from 'drizzle-orm'; +import * as tables from './db/schema.js'; +import { fail } from './security.js'; + +export const CHUNK_BYTES = 8 * 1024 * 1024; +export class ResumableUploads { + constructor(service) { + this.service = service; + this.directory = resolve(service.cfg.uploadDir, 'chunks'); + mkdirSync(this.directory, { recursive: true, mode: 0o700 }); + this.busy = new Set(); + } + path(id, extension) { + return resolve(this.directory, `${id}.${extension}`); + } + metadata(id) { + try { + return JSON.parse(readFileSync(this.path(id, 'json'), 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } + } + save(id, value) { + writeFileSync(this.path(id, 'next'), JSON.stringify(value), { mode: 0o600, flush: true }); + renameSync(this.path(id, 'next'), this.path(id, 'json')); + } + remove(id) { + for (const extension of ['part', 'json', 'next']) + rmSync(this.path(id, extension), { force: true }); + } + manifest(subject, requestId, position) { + return this.service.storage.uploadManifest(subject, requestId, position); + } + file(subject, id) { + const row = this.service.db.select().from(tables.files).where(eq(tables.files.id, id)).get(); + if (!row) fail(404, 'UPLOAD_NOT_FOUND', '업로드를 찾을 수 없습니다.'); + return this.manifest(subject, row.requestId, row.position); + } + async lock(id, action) { + if (this.busy.has(id)) + fail(409, 'UPLOAD_ACTIVE', '업로드 요청이 처리 중입니다. 잠시 후 다시 시도해 주세요.'); + this.busy.add(id); + try { + return await action(); + } finally { + this.busy.delete(id); + } + } + async state(subject, file, fingerprint) { + if (file.status === 'stored') + return { + uploadId: file.id, + offset: file.size, + size: file.size, + chunkSize: CHUNK_BYTES, + status: 'stored', + }; + let meta = this.metadata(file.id); + if (!meta) { + if (!fingerprint) fail(404, 'UPLOAD_NOT_STARTED', '먼저 업로드를 시작해 주세요.'); + meta = { + requestId: file.requestId, + subjectId: subject.id, + sessionId: subject.sessionId, + key: randomUUID(), + fingerprint, + hashes: [], + offset: 0, + updatedAt: Date.now(), + }; + writeFileSync(this.path(file.id, 'part'), '', { flag: 'w', mode: 0o600 }); + this.save(file.id, meta); + } + if (meta.subjectId !== subject.id || meta.sessionId !== subject.sessionId) + fail(409, 'UPLOAD_SESSION_CHANGED', '처음 업로드한 세션으로 다시 시도해 주세요.'); + if (fingerprint && meta.fingerprint !== fingerprint) + fail( + 409, + 'FILE_CHANGED', + '처음 선택한 파일과 내용이 다릅니다. 같은 파일로 다시 시도해 주세요.', + ); + const length = (await stat(this.path(file.id, 'part'))).size; + if (length < meta.offset) fail(409, 'UPLOAD_CORRUPTED', '업로드 자료를 다시 선택해 주세요.'); + // Recover a write interrupted between appending bytes and committing metadata. + if (length > meta.offset) await truncate(this.path(file.id, 'part'), meta.offset); + this.manifest(subject, file.requestId, file.position); + this.service.storage.reserve(subject, file.requestId); + return { + uploadId: file.id, + offset: meta.offset, + size: file.size, + chunkSize: CHUNK_BYTES, + status: 'waiting', + }; + } + async start(subject, requestId, position, fingerprint) { + const file = this.manifest(subject, requestId, position); + return this.lock(file.id, () => this.state(subject, file, fingerprint)); + } + async status(subject, id) { + return this.lock(id, () => this.state(subject, this.file(subject, id))); + } + async append(subject, id, offset, bytes) { + return this.lock(id, async () => { + const file = this.file(subject, id); + if (file.status === 'stored') fail(409, 'FILE_STORED', '이미 저장된 파일입니다.'); + const current = await this.state(subject, file), + meta = this.metadata(id); + if ( + !Buffer.isBuffer(bytes) || + offset % CHUNK_BYTES !== 0 || + bytes.length !== Math.min(CHUNK_BYTES, file.size - offset) || + !bytes.length + ) + fail(413, 'CHUNK_SIZE_INVALID', '업로드 조각 크기가 올바르지 않습니다.'); + const handle = await open(this.path(id, 'part'), 'r+'); + try { + if (offset < current.offset && offset + bytes.length <= current.offset) { + const previous = Buffer.alloc(bytes.length); + const result = await handle.read(previous, 0, previous.length, offset); + if (result.bytesRead !== bytes.length || !previous.equals(bytes)) + fail(409, 'CHUNK_CONFLICT', '이미 받은 조각과 내용이 다릅니다.'); + return current; + } + if (offset !== current.offset) + fail(409, 'UPLOAD_OFFSET', '서버가 받은 위치부터 다시 보내 주세요.'); + let written = 0; + while (written < bytes.length) { + const result = await handle.write( + bytes, + written, + bytes.length - written, + offset + written, + ); + if (!result.bytesWritten) throw Error('Unable to write upload chunk'); + written += result.bytesWritten; + } + await handle.sync(); + this.file(subject, id); + if (!this.metadata(id)) fail(409, 'UPLOAD_CANCELLED', '전송이 취소되었습니다.'); + meta.offset += bytes.length; + meta.hashes.push(createHash('sha256').update(bytes).digest('hex')); + meta.updatedAt = Date.now(); + this.save(id, meta); + return { ...current, offset: meta.offset }; + } finally { + await handle.close(); + } + }); + } + async complete(subject, id, request) { + return this.lock(id, async () => { + const file = this.file(subject, id); + if (file.status === 'stored') return { fileId: id, status: 'stored' }; + const current = await this.state(subject, file), + meta = this.metadata(id); + if (current.offset !== file.size) + fail(409, 'UPLOAD_INCOMPLETE', '파일을 모두 보낸 후 완료할 수 있습니다.'); + if ( + createHash('sha256') + .update(Buffer.from(meta.hashes.join(''), 'hex')) + .digest('hex') !== meta.fingerprint + ) + fail(409, 'FILE_CHECKSUM', '파일 내용 검증에 실패했습니다.'); + const path = this.path(id, 'part'); + const adapter = { + raw: request.raw, + async *parts() { + yield { type: 'file', filename: file.name, file: createReadStream(path) }; + }, + }; + try { + const result = await this.service.storage.upload( + subject, + file.requestId, + file.position, + adapter, + meta.key, + path, + ); + this.remove(id); + return result; + } catch (error) { + if (!existsSync(path)) this.remove(id); + throw error; + } + }); + } + abort(requestId) { + for (const file of this.service.storage.fileRows(requestId)) this.remove(file.id); + } + abortSession(sessionId) { + for (const name of readdirSync(this.directory)) { + if (!name.endsWith('.json')) continue; + const id = name.slice(0, -5); + if (this.metadata(id)?.sessionId === sessionId) this.remove(id); + } + } + abortUser(userId) { + for (const name of readdirSync(this.directory)) { + if (name.endsWith('.json') && this.metadata(name.slice(0, -5))?.subjectId === userId) + this.remove(name.slice(0, -5)); + } + } + cleanup() { + const now = Date.now(); + for (const name of readdirSync(this.directory)) { + if (!name.endsWith('.json')) continue; + const id = name.slice(0, -5), + meta = this.metadata(id); + const row = this.service.db + .select() + .from(tables.requests) + .where(eq(tables.requests.id, meta.requestId)) + .get(); + if ( + !this.busy.has(id) && + (row?.status !== 'accepted' || + !row.startBy || + row.startBy <= now || + meta.updatedAt < now - this.service.cfg.orphanHours * 3600000) + ) + this.remove(id); + } + for (const name of readdirSync(this.directory)) { + if (!/^[0-9a-f-]{36}\.(part|next)$/.test(name)) continue; + const id = name.split('.')[0]; + if ( + !this.busy.has(id) && + !existsSync(this.path(id, 'json')) && + statSync(resolve(this.directory, name)).mtimeMs < + now - this.service.cfg.orphanHours * 3600000 + ) + this.remove(id); + } + } +} diff --git a/apps/server/src/security.ts b/apps/server/src/security.ts index ea62cd5..468975b 100644 --- a/apps/server/src/security.ts +++ b/apps/server/src/security.ts @@ -28,6 +28,7 @@ export function clientIp( remote: string | undefined, headers: IncomingHttpHeaders, trusted: string[], + source: 'forwarded' | 'cloudflare' = 'forwarded', ): string | null { const first = remote ? normalizeIp(remote) : null; if (!first) return null; @@ -46,6 +47,13 @@ export function clientIp( return false; } }); + if (source === 'cloudflare') { + // Trust this single edge-written header only through the configured adjacent relay. + if (!trusts(first)) return null; + const raw = headers['cf-connecting-ip']; + const result = typeof raw === 'string' ? normalizeIp(raw.trim()) : null; + return publicIp(result) ? result : null; + } if (!trusts(first)) return first; const raw = headers['x-forwarded-for']; if (typeof raw !== 'string') return null; diff --git a/apps/server/src/service.ts b/apps/server/src/service.ts index 321d582..694788a 100644 --- a/apps/server/src/service.ts +++ b/apps/server/src/service.ts @@ -2,6 +2,7 @@ import type { Server } from 'socket.io'; import type { Auth } from './auth.js'; import type { Accounts } from './accounts.js'; import { Storage } from './storage.js'; +import { ResumableUploads } from './resumable.js'; import { Vault } from './vault.js'; import { randomInt, randomUUID } from 'node:crypto'; import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm'; @@ -28,12 +29,14 @@ export class Service { accounts!: Accounts; io?: Server; storage: Storage; + resumable: ResumableUploads; vault: Vault; constructor( readonly db: DB, readonly cfg: Config, ) { this.storage = new Storage(this); + this.resumable = new ResumableUploads(this); this.vault = new Vault(this); } guest(token: string | undefined) { diff --git a/apps/server/src/storage.ts b/apps/server/src/storage.ts index a0eeb7a..3e2e7ec 100644 --- a/apps/server/src/storage.ts +++ b/apps/server/src/storage.ts @@ -3,7 +3,7 @@ import { mkdirSync, statfsSync, existsSync } from 'node:fs'; import { createWriteStream, createReadStream } from 'node:fs'; import { rename, unlink, readdir, stat, open } from 'node:fs/promises'; import { resolve } from 'node:path'; -import { Transform } from 'node:stream'; +import { Transform, Writable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { and, eq, gt, isNull, inArray, sql, or } from 'drizzle-orm'; import sharp from 'sharp'; @@ -146,6 +146,7 @@ export class Storage { .run(); } release(requestId: string) { + this.service.resumable?.abort(requestId); const db = this.service.db; for (const file of this.fileRows(requestId)) { for (const [uploadId, upload] of this.active) @@ -161,12 +162,51 @@ export class Storage { .run(); } } + uploadManifest(g: StorageSubject, requestId: string, position: number) { + if (g.kind === 'user') { + const session = g.sessionId + ? this.service.db + .select() + .from(s.accountSessions) + .where(eq(s.accountSessions.id, g.sessionId)) + .get() + : null; + if ( + !session || + session.userId !== g.id || + session.revokedAt || + session.expiresAt <= Date.now() + ) + fail(401, 'SESSION_REVOKED', '계정 세션이 종료되었습니다.'); + } else { + const guest = this.service.db.select().from(s.guests).where(eq(s.guests.id, g.id)).get(); + if (!guest || guest.revokedAt || guest.expiresAt <= Date.now()) + fail(401, 'SESSION_EXPIRED', '기기 세션이 종료되었습니다.'); + } + const row = this.request(g, requestId); + if (row.kind !== 'file' || !this.isSender(g, row)) + fail(403, 'UPLOAD_FORBIDDEN', '발신자만 업로드할 수 있습니다.'); + if (this.service.vault.source(requestId)) + fail(409, 'VAULT_TRANSFER_MANAGED', '이미 저장된 파일을 전달합니다.'); + if ( + row.status !== 'accepted' || + !row.startBy || + row.startBy <= Date.now() || + this.blocked(g, row) + ) + fail(409, 'ACCEPTANCE_REQUIRED', '수락된 유효한 요청이 필요합니다.'); + const file = this.fileRows(requestId).find((f) => f.position === position); + if (!file) fail(404, 'FILE_NOT_REQUESTED', '요청에 없는 파일입니다.'); + if (file!.status === 'uploading') fail(409, 'UPLOAD_ACTIVE', '파일이 처리 중입니다.'); + return file!; + } async upload( g: StorageSubject, requestId: string, position: number, request: FastifyRequest, key: string, + stagedPath?: string, ) { const { db, cfg } = this.service; let file: typeof s.files.$inferSelect; @@ -230,7 +270,7 @@ export class Storage { .run(); }); const controller = new AbortController(), - tempPath = resolve(this.temp, uploadId!); + tempPath = stagedPath ?? resolve(this.temp, uploadId!); this.active.set(uploadId!, { guestId: `${g.kind ?? 'guest'}:${g.id}`, controller, @@ -265,7 +305,13 @@ export class Storage { await pipeline( part.file, counter, - createWriteStream(tempPath, { flags: 'wx', mode: 0o600 }), + stagedPath + ? new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }) + : createWriteStream(tempPath, { flags: 'wx', mode: 0o600 }), { signal: controller.signal }, ); if (part.file.truncated || bytes !== file!.size) @@ -486,6 +532,11 @@ export class Storage { ) .get(); if (row) { + if (g.kind === 'user' && row.item.fileId) { + const file = db.select().from(s.files).where(eq(s.files.id, row.item.fileId)).get(); + if (file && !this.service.accounts.canStoreFile(g.id, file)) + fail(413, 'ACCOUNT_QUOTA', '보관함 용량이 부족해 복원하지 못했습니다.'); + } db.update(s.access).set({ deletedAt: null }).where(eq(s.access.id, row.access.id)).run(); count++; } @@ -505,6 +556,7 @@ export class Storage { if (this.cleaning) return; this.cleaning = true; try { + this.service.resumable?.cleanup(); const { db, cfg } = this.service, now = Date.now(); const expired = db @@ -580,10 +632,12 @@ export class Storage { } } abortSession(id: string) { + this.service.resumable?.abortSession(id); for (const upload of this.active.values()) if (upload.sessionId === id) upload.controller.abort(); } abortUser(id: string) { + this.service.resumable?.abortUser(id); for (const upload of this.active.values()) if (upload.guestId === `user:${id}`) upload.controller.abort(); } diff --git a/apps/server/src/vault.js b/apps/server/src/vault.js index ea277c0..68ecd1c 100644 --- a/apps/server/src/vault.js +++ b/apps/server/src/vault.js @@ -36,6 +36,18 @@ export class Vault { ) return; if (!auth.serialize(auth.user(binding.userId)).verified) return; + const item = db.select().from(s.items).where(eq(s.items.id, itemId)).get(); + const file = item?.fileId + ? db.select().from(s.files).where(eq(s.files.id, item.fileId)).get() + : null; + if (file && !this.service.accounts.canStoreFile(binding.userId, file)) { + queueMicrotask(() => + this.service.io + ?.to(`guest:${request.recipientGuestId}`) + .emit('receipt:skipped', { accountId: binding.userId, reason: 'quota' }), + ); + return; + } db.insert(s.access) .values({ id: randomUUID(), itemId, userId: binding.userId, role: 'recipient', read: false }) .onConflictDoNothing() diff --git a/apps/server/test/cloudflare-discovery.test.js b/apps/server/test/cloudflare-discovery.test.js new file mode 100644 index 0000000..334d2a4 --- /dev/null +++ b/apps/server/test/cloudflare-discovery.test.js @@ -0,0 +1,74 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { io } from 'socket.io-client'; +import { fixture } from './helpers.js'; +import { clientIp } from '../src/security.js'; + +test('Cloudflare 원본 IP는 신뢰한 인접 프록시의 단일 공인 IP만 허용한다', () => { + const resolve = (peer, headers) => clientIp(peer, headers, ['127.0.0.1'], 'cloudflare'); + assert.equal( + resolve('127.0.0.1', { 'cf-connecting-ip': '8.8.8.8', 'x-forwarded-for': '1.1.1.1' }), + '8.8.8.8', + ); + assert.equal(resolve('8.8.4.4', { 'cf-connecting-ip': '8.8.8.8' }), null); + for (const value of ['192.168.1.1', '127.0.0.1', 'bad', '8.8.8.8,1.1.1.1', ['8.8.8.8']]) + assert.equal(resolve('127.0.0.1', { 'cf-connecting-ip': value }), null); + assert.equal(resolve('127.0.0.1', {}), null); +}); +test('실제 소켓의 같은 원본 IP는 자동 발견하고 다른 네트워크·발견 꺼짐은 숨긴다', async () => { + const f = await fixture(true), + sockets = []; + f.cfg.clientIpSource = 'cloudflare'; + try { + const clients = []; + for (const ip of ['8.8.8.8', '8.8.8.8', '1.1.1.1']) { + const client = await f.create(ip, false); + const socket = io(f.url, { + transports: ['websocket'], + auth: { csrfToken: client.csrf }, + extraHeaders: { + Origin: f.cfg.appUrl, + Cookie: client.cookie, + 'CF-Connecting-IP': ip, + 'X-Forwarded-For': '9.9.9.9', + }, + }); + sockets.push(socket); + await new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('connect_error', reject); + }); + clients.push(client); + } + const list = (await f.call(clients[0], '/discovery/devices')).json().devices; + assert.deepEqual( + list.map((device) => device.id), + [clients[1].deviceId], + ); + await f.call(clients[1], '/device/me', 'PATCH', { discoverable: false }); + assert.equal((await f.call(clients[0], '/discovery/devices')).json().devices.length, 0); + } finally { + sockets.forEach((socket) => socket.disconnect()); + await f.close(); + } +}); +test('프록시 진단은 운영자 토큰 없이 원본 정보를 노출하지 않는다', async () => { + const f = await fixture(); + f.cfg.proxyDiagnosticToken = 'p'.repeat(40); + try { + const denied = await f.call(null, '/ops/proxy-diagnostic'); + assert.equal(denied.statusCode, 403); + assert.equal(denied.json().peerIp, undefined); + const allowed = await f.call(null, '/ops/proxy-diagnostic', 'GET', undefined, undefined, { + authorization: `Bearer ${f.cfg.proxyDiagnosticToken}`, + 'cf-connecting-ip': '8.8.8.8', + }); + assert.equal(allowed.statusCode, 200); + assert.equal(allowed.json().peerIp, '127.0.0.1'); + assert.equal(allowed.json().cloudflareClientIpPresent, true); + f.cfg.proxyDiagnosticToken = undefined; + assert.equal((await f.call(null, '/ops/proxy-diagnostic')).statusCode, 404); + } finally { + await f.close(); + } +}); diff --git a/apps/server/test/phase3.test.ts b/apps/server/test/phase3.test.ts index d4424c2..1ff0174 100644 --- a/apps/server/test/phase3.test.ts +++ b/apps/server/test/phase3.test.ts @@ -418,14 +418,14 @@ test('기존 이메일 자료: 계정 파일은 수락 후 저장되고 익명 const item = (await f.call(b, '/inbox')).json().items[0], download = await f.call(b, `/access/${item.accessId}/download`); assert.deepEqual(download.rawPayload, bytes); - assert.equal((await f.call(a, '/account/storage')).json().usedBytes, bytes.length); - assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0); + assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 0); + assert.equal((await f.call(b, '/account/storage')).json().usedBytes, bytes.length); assert.equal( (await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: true })) .statusCode, 410, ); - assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0); + assert.equal((await f.call(b, '/account/storage')).json().usedBytes, bytes.length); } finally { await f.close(); } diff --git a/apps/server/test/resumable.test.js b/apps/server/test/resumable.test.js new file mode 100644 index 0000000..239f317 --- /dev/null +++ b/apps/server/test/resumable.test.js @@ -0,0 +1,182 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { readdir, readFile, writeFile } from 'node:fs/promises'; +import { eq } from 'drizzle-orm'; +import { fixture } from './helpers.js'; +import { CHUNK_BYTES, ResumableUploads } from '../src/resumable.js'; +import * as schema from '../src/db/schema.js'; + +const digest = (bytes) => createHash('sha256').update(bytes).digest(); +function fingerprint(bytes) { + const hashes = []; + for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) + hashes.push(digest(bytes.subarray(offset, offset + CHUNK_BYTES))); + return digest(Buffer.concat(hashes)).toString('hex'); +} +async function prepare(f, bytes, accept = true) { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const response = await f.call( + a, + '/transfer-requests', + 'POST', + { + targetDeviceId: b.deviceId, + kind: 'file', + byteSize: bytes.length, + count: 1, + files: [{ name: 'resumable.bin', size: bytes.length, type: 'application/octet-stream' }], + }, + randomUUID(), + ); + assert.equal(response.statusCode, 200, response.body); + const requestId = response.json().requestId; + if (accept) + assert.equal( + (await f.call(b, `/transfer-requests/${requestId}/accept`, 'POST', {}, randomUUID())) + .statusCode, + 200, + ); + const start = (sender = a, content = bytes) => + f.call(sender, '/uploads/resumable', 'POST', { + requestId, + fileIndex: 0, + fingerprint: fingerprint(content), + }); + return { a, b, requestId, start }; +} +const put = (f, sender, id, offset, bytes) => + f.call(sender, `/uploads/resumable/${id}`, 'PUT', bytes, undefined, { + 'content-type': 'application/octet-stream', + 'upload-offset': String(offset), + }); + +test('분할 업로드는 수락·발신 권한·Origin·CSRF를 검사한다', async () => { + const f = await fixture(); + try { + const p = await prepare(f, Buffer.from('hello'), false); + assert.equal((await p.start()).statusCode, 409); + await f.call(p.b, `/transfer-requests/${p.requestId}/accept`, 'POST', {}, randomUUID()); + assert.equal((await p.start(p.b)).statusCode, 403); + const state = (await p.start()).json(); + assert.equal( + ( + await f.call( + p.a, + `/uploads/resumable/${state.uploadId}`, + 'PUT', + Buffer.from('hello'), + undefined, + { + 'content-type': 'application/octet-stream', + 'upload-offset': '0', + origin: 'https://evil.test', + }, + ) + ).statusCode, + 403, + ); + assert.equal( + ( + await f.call( + p.a, + `/uploads/resumable/${state.uploadId}`, + 'PUT', + Buffer.from('hello'), + undefined, + { + 'content-type': 'application/octet-stream', + 'upload-offset': '0', + 'x-csrf-token': 'wrong', + }, + ) + ).statusCode, + 403, + ); + } finally { + await f.close(); + } +}); +test('받은 조각의 중복·다른 내용·순서를 검사하고 이어 받아 원본을 확정한다', async () => { + const f = await fixture(); + try { + const bytes = Buffer.alloc(CHUNK_BYTES + 13, 71), + p = await prepare(f, bytes); + const state = (await p.start()).json(), + path = `/uploads/resumable/${state.uploadId}`; + const first = bytes.subarray(0, CHUNK_BYTES); + assert.equal( + (await put(f, p.a, state.uploadId, CHUNK_BYTES, bytes.subarray(CHUNK_BYTES))).json().code, + 'UPLOAD_OFFSET', + ); + assert.equal((await put(f, p.a, state.uploadId, 0, first)).json().offset, CHUNK_BYTES); + assert.equal((await put(f, p.a, state.uploadId, 0, first)).json().offset, CHUNK_BYTES); + assert.equal( + (await put(f, p.a, state.uploadId, 0, Buffer.alloc(CHUNK_BYTES, 72))).json().code, + 'CHUNK_CONFLICT', + ); + assert.equal( + (await f.call(p.a, `${path}/complete`, 'POST', {})).json().code, + 'UPLOAD_INCOMPLETE', + ); + f.service.resumable = new ResumableUploads(f.service); + assert.equal((await p.start()).json().offset, CHUNK_BYTES); + assert.equal((await p.start(p.a, Buffer.alloc(bytes.length, 73))).json().code, 'FILE_CHANGED'); + assert.equal( + (await put(f, p.a, state.uploadId, CHUNK_BYTES, bytes.subarray(CHUNK_BYTES))).statusCode, + 200, + ); + assert.equal((await f.call(p.a, `${path}/complete`, 'POST', {})).statusCode, 200); + assert.equal((await f.call(p.a, `${path}/complete`, 'POST', {})).statusCode, 200); + const item = (await f.call(p.b, '/device/items')).json().items[0]; + const download = await f.call(p.b, `/access/${item.accessId}/download`); + assert.deepEqual(download.rawPayload, bytes); + assert.equal((await readdir(f.service.resumable.directory)).length, 0); + } finally { + await f.close(); + } +}); +test('8MiB 초과·전체 파일 검증 실패·취소는 자료 확정을 막고 임시 파일을 정리한다', async () => { + const f = await fixture(); + try { + const bytes = Buffer.from('original'), + p = await prepare(f, bytes); + const state = (await p.start()).json(); + assert.equal( + (await put(f, p.a, state.uploadId, 0, Buffer.alloc(CHUNK_BYTES + 1))).statusCode, + 413, + ); + assert.equal((await put(f, p.a, state.uploadId, 0, Buffer.from('tampered'))).statusCode, 200); + assert.equal( + (await f.call(p.a, `/uploads/resumable/${state.uploadId}/complete`, 'POST', {})).json().code, + 'FILE_CHECKSUM', + ); + assert.equal((await f.call(p.b, '/device/items')).json().items.length, 0); + await f.call(p.a, `/transfer-requests/${p.requestId}/cancel`, 'POST', {}); + assert.equal((await readdir(f.service.resumable.directory)).length, 0); + assert.equal((await put(f, p.a, state.uploadId, 0, bytes)).statusCode, 409); + } finally { + await f.close(); + } +}); +test('메타데이터 저장 전 중단된 쓰기는 마지막 확정 위치로 복구하고 만료 조각을 정리한다', async () => { + const f = await fixture(); + try { + const p = await prepare(f, Buffer.from('crash')), + state = (await p.start()).json(); + await writeFile(f.service.resumable.path(state.uploadId, 'part'), 'uncommitted'); + assert.equal((await p.start()).json().offset, 0); + assert.equal((await readFile(f.service.resumable.path(state.uploadId, 'part'))).length, 0); + f.service.db + .update(schema.requests) + .set({ startBy: Date.now() - 1 }) + .where(eq(schema.requests.id, p.requestId)) + .run(); + f.service.resumable.cleanup(); + assert.equal((await readdir(f.service.resumable.directory)).length, 0); + } finally { + await f.close(); + } +}); diff --git a/apps/server/test/vault.test.js b/apps/server/test/vault.test.js index 10b0794..5bff97e 100644 --- a/apps/server/test/vault.test.js +++ b/apps/server/test/vault.test.js @@ -45,6 +45,96 @@ function limits(f) { f.cfg.ipRate = 60; } +test('개인 보관함은 임시 저장·받은 파일을 합산하고 초과 수신은 다운로드만 유지한다', async () => { + const f = await fixture(); + try { + limits(f); + f.cfg.userQuota = 7; + const sender = await f.create(), + receiver = await f.create(), + outsider = await f.create(); + await f.space(sender, receiver); + await signed(f, receiver, 'quota@hop.test'); + await signed(f, outsider, 'other-quota@hop.test'); + const saved = ( + await f.call( + receiver, + '/vault', + 'POST', + { + kind: 'file', + files: [{ name: 'saved.txt', size: 4, type: 'text/plain' }], + }, + randomUUID(), + ) + ).json(); + assert.equal((await upload(f, receiver, saved.requestId, Buffer.from('save'))).statusCode, 200); + async function received(bytes) { + const r = await f.call( + sender, + '/transfer-requests', + 'POST', + { + targetDeviceId: receiver.deviceId, + kind: 'file', + byteSize: bytes.length, + count: 1, + files: [{ name: 'saved.txt', size: bytes.length, type: 'text/plain' }], + }, + randomUUID(), + ); + const id = r.json().requestId; + assert.equal( + (await f.call(receiver, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())) + .statusCode, + 200, + ); + assert.equal((await upload(f, sender, id, bytes)).statusCode, 200); + return id; + } + const skipped = await received(Buffer.from('full')); + assert.equal((await f.call(receiver, '/inbox')).json().items.length, 0); + const guestItem = (await f.call(receiver, '/device/items')) + .json() + .items.find((item) => item.file?.size === 4 && item.role === 'recipient'); + assert.deepEqual( + (await f.call(receiver, `/access/${guestItem.accessId}/download`)).rawPayload, + Buffer.from('full'), + ); + await received(Buffer.from('fit')); + assert.equal((await f.call(receiver, '/account/storage')).json().usedBytes, 7); + assert.equal((await f.call(outsider, '/account/storage')).json().usedBytes, 0); + const own = (await f.call(receiver, '/vault')).json().items[0]; + const deleted = ( + await f.call(receiver, '/access/delete-batch', 'POST', { ids: [own.accessId] }) + ).json(); + assert.equal((await f.call(receiver, '/account/storage')).json().usedBytes, 3); + const replacement = ( + await f.call( + receiver, + '/vault', + 'POST', + { + kind: 'file', + files: [{ name: 'saved.txt', size: 4, type: 'text/plain' }], + }, + randomUUID(), + ) + ).json(); + assert.equal( + (await upload(f, receiver, replacement.requestId, Buffer.from('more'))).statusCode, + 200, + ); + assert.equal( + (await f.call(receiver, `/deletion-operations/${deleted.operationId}/undo`, 'POST', {})) + .statusCode, + 413, + ); + } finally { + await f.close(); + } +}); + test('기기 수신은 로그인 계정의 날짜 기록에만 자동 저장, 익명 과거 자료와 로그아웃 후 도착은 가져오지 않음', async () => { const f = await fixture(); try { diff --git a/apps/web/index.html b/apps/web/index.html index f7a34c2..0eefe4e 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -6,6 +6,10 @@ hop. — 기기 사이를 가볍게. + + + +
diff --git a/apps/web/public/icon-192.png b/apps/web/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..63848aecad40ec39616ef62ac85a1a4108bb9a00 GIT binary patch literal 3675 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 IPP)eZ4+#dIzW@LL literal 0 HcmV?d00001 diff --git a/apps/web/public/icon-512.png b/apps/web/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..1e8f541e2aa5182a736ff22cc36ecaed0525557c GIT binary patch literal 14234 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 literal 0 HcmV?d00001 diff --git a/apps/web/public/icon-maskable.png b/apps/web/public/icon-maskable.png new file mode 100644 index 0000000000000000000000000000000000000000..834dbe27bfe57e844544f1c3f1288a408c638350 GIT binary patch literal 10134 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 literal 0 HcmV?d00001 diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest new file mode 100644 index 0000000..158967a --- /dev/null +++ b/apps/web/public/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "id": "/", + "name": "hop. — 기기 사이를 가볍게.", + "short_name": "hop.", + "description": "가입 없이 주변 기기에 파일과 링크를 보내세요.", + "lang": "ko", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#000000", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 0000000..1e705eb --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,18 @@ +// Network-only: no account records, files, tokens or API responses enter offline caches. +self.addEventListener('install', (event) => event.waitUntil(self.skipWaiting())); +self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET' || event.request.mode !== 'navigate') return; + event.respondWith( + fetch(event.request).catch( + () => + new Response( + 'hop.

hop.

인터넷 연결이 필요해요.

연결한 뒤 다시 열어주세요.

다시 시도', + { + status: 503, + headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, + }, + ), + ), + ); +}); diff --git a/apps/web/src/AccountPanel.vue b/apps/web/src/AccountPanel.vue index 1aa8f8c..8da7d38 100644 --- a/apps/web/src/AccountPanel.vue +++ b/apps/web/src/AccountPanel.vue @@ -84,11 +84,13 @@ async function act(fn) { } } function size(value) { - return value >= 1048576 - ? (value / 1048576).toFixed(1) + ' MiB' - : value >= 1024 - ? (value / 1024).toFixed(1) + ' KiB' - : value + ' B'; + return value >= 1073741824 + ? (value / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GiB' + : value >= 1048576 + ? (value / 1048576).toFixed(1) + ' MiB' + : value >= 1024 + ? (value / 1024).toFixed(1) + ' KiB' + : value + ' B'; } function date(value) { return new Intl.DateTimeFormat('ko-KR', { @@ -229,39 +231,65 @@ async function authSubmit() { } async function save(values) { - if (busy.value || saveTransfer.value) return; + if (busy.value) return; + if ( + saveTransfer.value && + !Object.entries(uploadProgress.value).some( + ([key, value]) => key.startsWith(saveTransfer.value.id + ':') && value.status === 'failed', + ) + ) + return; busy.value = true; - let id; + let id = saveTransfer.value?.id; try { - const result = await api( - '/vault', - 'POST', - { - kind: values.files ? 'file' : 'url', - ...(values.files - ? { - files: values.files.map((file) => ({ - name: file.name, - size: file.size, - type: file.type || 'application/octet-stream', - })), - } - : { urls: values.urls }), - note: note.value, - }, - crypto.randomUUID(), - ); + const result = id + ? { requestId: id } + : await api( + '/vault', + 'POST', + { + kind: values.files ? 'file' : 'url', + ...(values.files + ? { + files: values.files.map((file) => ({ + name: file.name, + size: file.size, + type: file.type || 'application/octet-stream', + })), + } + : { urls: values.urls }), + note: note.value, + }, + crypto.randomUUID(), + ); id = result.requestId; if (values.files) { - saveTransfer.value = { id, kind: 'file', status: 'accepted' }; + if ( + saveTransfer.value?.files && + (values.files.length !== saveTransfer.value.files.length || + values.files.some( + (file, index) => + file.name !== saveTransfer.value.files[index].name || + file.size !== saveTransfer.value.files[index].size, + )) + ) + throw Error('처음 선택한 파일과 이름·크기가 같은 파일을 선택해 주세요.'); + saveTransfer.value = { + id, + kind: 'file', + status: 'accepted', + files: values.files.map((file) => ({ name: file.name, size: file.size })), + }; busy.value = false; for (const [index, file] of values.files.entries()) { if (saveTransfer.value?.id !== id) return; + if (uploadProgress.value[`${id}:${index}`]?.status === 'stored') continue; await uploadFile(id, index, file, account.value.id); } if (saveTransfer.value?.id !== id) return; } await refreshLists(); + saveTransfer.value = null; compose.value = false; note.value = ''; message('임시 보관함에 저장했습니다.'); @@ -269,7 +297,6 @@ async function save(values) { if (!id || saveTransfer.value?.id === id || !values.files) showError(error); } finally { busy.value = false; - if (saveTransfer.value?.id === id) saveTransfer.value = null; } } async function cancelSave() { @@ -638,7 +665,7 @@ async function resendVerification() {

- 저장 용량 {{ size(storage.usedBytes) }} / {{ size(storage.limitBytes) }} + 내 보관함 {{ size(storage.usedBytes) }} / {{ size(storage.limitBytes) }} · 최대 7일

+ -
+
+ +
+