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 0000000..63848ae Binary files /dev/null and b/apps/web/public/icon-192.png differ diff --git a/apps/web/public/icon-512.png b/apps/web/public/icon-512.png new file mode 100644 index 0000000..1e8f541 Binary files /dev/null and b/apps/web/public/icon-512.png differ diff --git a/apps/web/public/icon-maskable.png b/apps/web/public/icon-maskable.png new file mode 100644 index 0000000..834dbe2 Binary files /dev/null and b/apps/web/public/icon-maskable.png differ 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일

+ -
+
+ +
+