Add resumable uploads, proxy discovery, PWA install and 2 GiB personal vaults

This commit is contained in:
2026-10-12 15:54:26 +09:00
parent 439c2ae61b
commit 464715aa69
43 changed files with 1647 additions and 137 deletions

View File

@@ -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)

View File

@@ -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({

View File

@@ -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');

View File

@@ -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. <hello@noname.company>',
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,
};

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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();
}

View File

@@ -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()

View File

@@ -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();
}
});

View File

@@ -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();
}

View File

@@ -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();
}
});

View File

@@ -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 {

View File

@@ -6,6 +6,10 @@
<meta name="theme-color" content="#000000" />
<title>hop. — 기기 사이를 가볍게.</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="hop." />
</head>
<body>
<div id="app"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@@ -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" }
]
}

18
apps/web/public/sw.js Normal file
View File

@@ -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(
'<!doctype html><html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>hop.</title><body style="background:#000;color:#f5f5f5;font:16px system-ui;text-align:center;padding:25vh 24px"><h1>hop.</h1><p>인터넷 연결이 필요해요.</p><p>연결한 뒤 다시 열어주세요.</p><a href="/" style="color:inherit">다시 시도</a></body></html>',
{
status: 503,
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
},
),
),
);
});

View File

@@ -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() {
</div>
<button v-if="nextCursor" class="button load-more" @click="loadMoreAccount">더 보기</button>
<p class="scope-note">
저장 용량 {{ size(storage.usedBytes) }} / {{ size(storage.limitBytes) }}
내 보관함 {{ size(storage.usedBytes) }} / {{ size(storage.limitBytes) }} · 최대 7일
</p>
</section>
<div v-if="compose" class="modal-backdrop" @click.self="!saveTransfer && (compose = false)">

View File

@@ -33,12 +33,14 @@ import {
import QRCode from 'qrcode';
import AccountPanel from './AccountPanel.vue';
import SendComposer from './SendComposer.vue';
import InstallButton from './InstallButton.vue';
import { createDeliveryReceiver } from './deliveryReceiver';
import { initialTheme, applyTheme as setTheme } from './theme';
import {
device,
account,
connected,
discoveryEnabled,
peers,
requests,
items,
@@ -54,6 +56,7 @@ import {
markRead,
uploadProgress,
sendFiles,
retryFiles,
cancelTransfer,
download,
} from './client';
@@ -358,12 +361,16 @@ onMounted(async () => {
}
});
const onFocus = () => void refresh().catch(showError);
const onReceiptSkipped = () =>
notify('보관함 용량이 부족해 받은 항목에 저장하지 않았어요. 파일 다운로드는 진행됩니다.');
window.addEventListener('hop-receipt-skipped', onReceiptSkipped);
const onVisibility = () => {
if (document.visibilityState === 'visible') onFocus();
};
window.addEventListener('focus', onFocus);
document.addEventListener('visibilitychange', onVisibility);
onUnmounted(() => {
window.removeEventListener('hop-receipt-skipped', onReceiptSkipped);
window.removeEventListener('focus', onFocus);
document.removeEventListener('visibilitychange', onVisibility);
clearInterval(timer);
@@ -421,7 +428,9 @@ async function submitFiles(files) {
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
forwardedItem.value = null;
itemToSend.value = null;
await sendFiles(selected.value.id, files);
if (currentTransfer.value?.kind === 'file' && currentTransfer.value.status === 'accepted')
await retryFiles(currentTransfer.value.id, files);
else await sendFiles(selected.value.id, files);
});
}
async function createSpace() {
@@ -509,6 +518,7 @@ async function spaceAction(space, action, memberId) {
<span class="header-tagline">기기 사이를 가볍게.</span>
</div>
<div class="header-actions">
<InstallButton />
<button
class="icon-button theme-toggle"
:aria-label="theme === 'light' ? '다크 테마로 전환' : '라이트 테마로 전환'"
@@ -628,7 +638,27 @@ async function spaceAction(space, action, memberId) {
</Teleport>
<template v-if="tab === 'devices'">
<section class="devices-stage">
<div class="stage-heading"><h1>어디로 보낼까요?</h1></div>
<div class="stage-heading">
<h1>어디로 보낼까요?</h1>
<p class="discovery-status" role="status">
<LoaderCircle
v-if="connected && device?.discoverable && (spaces.length || discoveryEnabled)"
class="spin"
:size="13"
/>
{{
!connected
? '연결을 확인하고 있어요.'
: spaces.length
? '방 안의 기기를 확인하고 있어요.'
: !device?.discoverable
? '발견 허용이 꺼져 있어요.'
: discoveryEnabled
? '주변 기기를 계속 찾고 있어요.'
: '주변 검색 설정이 필요해요. QR로 연결할 수 있어요.'
}}
</p>
</div>
<section class="nearby-section">
<div class="section-heading">
<div>
@@ -878,6 +908,9 @@ async function spaceAction(space, action, memberId) {
네트워크가 달라도 연결할 수 있어요.<br />연결한 뒤에도 전송마다 수락이 필요합니다.
</p>
<div v-if="invite && invite.space.expiresAt > now" class="invite-display">
<p class="discovery-status">
<LoaderCircle class="spin" :size="14" />기기 참여를 기다리고 있어요.
</p>
<img :src="qr" alt="임시 연결 공간 초대 QR 코드" width="208" height="208" />
<p v-if="localOnlyQr" class="local-qr-note">
현재 QR은 이 PC의 개발 주소입니다. 휴대폰 접속은 아직 설정하지 않았어요. 같은 PC의

View File

@@ -0,0 +1,94 @@
<script setup>
import { ref, shallowRef, computed, onMounted, onUnmounted, nextTick } from 'vue';
import { Download, X } from '@lucide/vue';
const standalone = window.matchMedia('(display-mode: standalone)');
const installed = ref(standalone.matches || navigator.standalone === true);
const prompt = shallowRef(null),
help = ref(false),
button = ref(null),
closeButton = ref(null);
const ios =
/iPhone|iPad|iPod/.test(navigator.userAgent) ||
(/Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
const mobile = ios || /Android/.test(navigator.userAgent);
const visible = computed(() => !installed.value && (mobile || prompt.value));
function available(event) {
event.preventDefault();
prompt.value = event;
}
function didInstall() {
installed.value = true;
prompt.value = null;
help.value = false;
}
function displayChanged() {
installed.value = standalone.matches || navigator.standalone === true;
}
async function install() {
if (prompt.value) {
const event = prompt.value;
prompt.value = null;
try {
await event.prompt();
if ((await event.userChoice).outcome === 'accepted') didInstall();
} catch {
help.value = true;
}
} else help.value = true;
if (help.value) {
await nextTick();
closeButton.value?.focus();
}
}
function close() {
help.value = false;
button.value?.focus();
}
onMounted(() => {
window.addEventListener('beforeinstallprompt', available);
window.addEventListener('appinstalled', didInstall);
standalone.addEventListener('change', displayChanged);
});
onUnmounted(() => {
window.removeEventListener('beforeinstallprompt', available);
window.removeEventListener('appinstalled', didInstall);
standalone.removeEventListener('change', displayChanged);
});
</script>
<template>
<button
v-if="visible"
ref="button"
class="pwa-install-button"
aria-label="hop. 앱 설치"
@click="install"
>
<Download :size="16" /><span>설치</span>
</button>
<Teleport to="body">
<div v-if="help" class="modal-backdrop" @click.self="close" @keydown.esc.stop="close">
<section class="modal install-help" role="dialog" aria-modal="true" aria-label="앱 설치 안내">
<header>
<h2>홈 화면에 hop.</h2>
<button
ref="closeButton"
class="icon-button"
aria-label="설치 안내 닫기"
@click="close"
@keydown.tab.prevent
>
<X :size="20" />
</button>
</header>
<p v-if="ios">
Safari에서 공유 버튼을 누른 뒤<br /><strong>홈 화면에 추가</strong>를 선택하세요.
</p>
<p v-else>
브라우저 메뉴에서<br /><strong>앱 설치</strong> 또는 <strong>홈 화면에 추가</strong>를
선택하세요.
</p>
<small>설치 후 홈 화면에서 바로 열 수 있어요.</small>
</section>
</div>
</Teleport>
</template>

View File

@@ -45,7 +45,16 @@ const validUrl = computed(
() =>
parsed.value.urls.length > 0 && parsed.value.urls.length <= 20 && !parsed.value.invalid.length,
);
const mutable = computed(() => !props.busy && !props.disabled && !props.transfer);
const retryable = computed(
() =>
props.transfer?.kind === 'file' &&
props.transfer.status === 'accepted' &&
!props.transferSource &&
(!props.progress?.length || props.progress.some((item) => item.status === 'failed')),
);
const mutable = computed(
() => !props.busy && !props.disabled && (!props.transfer || retryable.value),
);
const ready = computed(() =>
source.value === 'url'
? validUrl.value && !reading.value
@@ -56,6 +65,7 @@ const ready = computed(() =>
async function loadClipboard(force = false) {
if (
!props.targetKey ||
props.transfer ||
!props.readClipboardOnSelection ||
props.contentMode === 'file' ||
!mutable.value ||
@@ -129,6 +139,9 @@ watch(
if (transfer && props.transferSource) {
source.value = 'vault';
selectedVault.value = props.transferSource;
} else if (transfer?.kind === 'file') {
source.value = 'file';
urlInput.value = '';
}
},
);
@@ -233,7 +246,7 @@ function pasted(event) {
choose(event.clipboardData.files);
return;
}
if (props.contentMode === 'file') return;
if (props.contentMode === 'file' || props.transfer) return;
const result = parseUrls(event.clipboardData?.getData('text/plain') ?? '');
if (result.urls.length && !result.invalid.length) {
event.preventDefault();
@@ -245,7 +258,7 @@ function pasted(event) {
}
}
function submit() {
if (props.transfer) {
if (props.transfer && !retryable.value) {
emit('cancel');
return;
}
@@ -387,7 +400,13 @@ function size(value) {
>
</template>
<p v-if="transfer" class="composer-transfer-state" role="status">
{{ transfer.status === 'pending' ? '상대 기기의 수락을 기다립니다.' : '전송 중입니다.' }}
{{
retryable
? '같은 파일로 이어 보낼 수 있어요.'
: transfer.status === 'pending'
? '상대 기기의 수락을 기다립니다.'
: '전송 중입니다.'
}}
</p>
<div v-for="(upload, index) in progress" :key="index" class="composer-progress">
<span
@@ -399,13 +418,16 @@ function size(value) {
<button
class="button primary content-submit"
:class="{ 'link-send': source === 'url', 'send-button': source === 'file' }"
:disabled="busy || disabled || (!transfer && !ready)"
:disabled="busy || disabled || ((!transfer || retryable) && !ready)"
@click="submit"
>
{{ transfer ? '전송 취소' : saveMode ? '저장하기' : '전송하기'
}}<X v-if="transfer" :size="16" /><ArrowUpRight v-else :size="16" />
{{ retryable ? '다시 전송' : transfer ? '전송 취소' : saveMode ? '저장하기' : '전송하기'
}}<X v-if="transfer && !retryable" :size="16" /><ArrowUpRight v-else :size="16" />
</button>
<div v-if="!contentMode" class="content-switches">
<div v-if="retryable" class="content-switches">
<button class="text-button room-leave" @click="emit('cancel')">전송 취소</button>
</div>
<div v-else-if="!contentMode && !transfer" class="content-switches">
<button v-if="source !== 'file'" class="text-button" :disabled="!mutable" @click="pickFiles">
<FileIcon :size="14" />대신 파일 선택하기</button
><button v-if="source !== 'url'" class="text-button" :disabled="!mutable" @click="toLink">

View File

@@ -28,9 +28,10 @@ let socket;
let reauthorizing;
const drafts = new Map();
const completing = new Set();
export async function api(path, method = 'GET', body, key) {
export async function api(path, method = 'GET', body, key, signal) {
const response = await fetch(`/api${path}`, {
method,
signal,
credentials: 'same-origin',
headers: {
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
@@ -40,7 +41,11 @@ export async function api(path, method = 'GET', body, key) {
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.message ?? '요청을 처리하지 못했습니다.');
if (!response.ok)
throw Object.assign(new Error(payload.message ?? '요청을 처리하지 못했습니다.'), {
status: response.status,
code: payload.code,
});
return payload;
}
export async function bootstrap() {
@@ -68,6 +73,10 @@ function connect() {
connected.value = false;
});
socket.on('inbox:updated', () => window.dispatchEvent(new Event('hop-account-updated')));
socket.on('receipt:skipped', (event) => {
if (event.accountId === account.value?.id)
window.dispatchEvent(new Event('hop-receipt-skipped'));
});
socket.on('account:revoked', () => {
clearAccountUploads(account.value?.id);
account.value = null;
@@ -232,73 +241,156 @@ export async function sendFiles(targetDeviceId, files) {
await refresh();
return requestId;
}
export function uploadFile(id, index, file, accountId) {
const progressKey = `${id}:${index}`;
const chunkBytes = 8 * 1024 * 1024;
async function fileFingerprint(file, signal) {
const digests = [];
for (let offset = 0; offset < file.size; offset += chunkBytes) {
signal.throwIfAborted();
const bytes = await file.slice(offset, offset + chunkBytes).arrayBuffer();
digests.push(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)));
}
signal.throwIfAborted();
const combined = new Uint8Array(digests.length * 32);
digests.forEach((digest, index) => combined.set(digest, index * 32));
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', combined))]
.map((value) => value.toString(16).padStart(2, '0'))
.join('');
}
export async function uploadFile(id, index, file, accountId) {
const progressKey = `${id}:${index}`,
controller = new AbortController();
uploadProgress.value[progressKey] = {
name: file.name,
progress: 0,
status: 'uploading',
accountId,
};
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
uploadRequests.set(progressKey, xhr);
xhr.open('POST', `/api/uploads?requestId=${id}&fileIndex=${index}`);
xhr.setRequestHeader('X-CSRF-Token', csrfToken.value);
xhr.setRequestHeader('Idempotency-Key', crypto.randomUUID());
xhr.upload.onprogress = (e) => {
if (e.lengthComputable)
uploadProgress.value[progressKey].progress = Math.min(
100,
Math.round((e.loaded / e.total) * 100),
);
};
const finish = () => uploadRequests.delete(progressKey);
xhr.onload = () => {
finish();
if (accountId && account.value?.id !== accountId) {
delete uploadProgress.value[progressKey];
reject(Error('계정 세션이 종료되었습니다.'));
return;
}
if (xhr.status >= 200 && xhr.status < 300) {
uploadProgress.value[progressKey] = { name: file.name, progress: 100, status: 'stored' };
resolve();
} else {
let message = '업로드하지 못했습니다.';
uploadRequests.set(progressKey, controller);
const check = () => {
controller.signal.throwIfAborted();
if (cancelledTransfers.has(id)) throw Error('전송이 취소되었습니다.');
if (accountId && account.value?.id !== accountId) throw Error('계정 세션이 종료되었습니다.');
};
const sendChunk = (uploadId, offset) =>
new Promise((resolve, reject) => {
check();
const xhr = new XMLHttpRequest();
const abort = () => xhr.abort();
const finish = () => controller.signal.removeEventListener('abort', abort);
controller.signal.addEventListener('abort', abort, { once: true });
xhr.open('PUT', `/api/uploads/resumable/${uploadId}`);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.setRequestHeader('X-CSRF-Token', csrfToken.value);
xhr.setRequestHeader('Upload-Offset', String(offset));
xhr.upload.onprogress = (event) => {
if (uploadProgress.value[progressKey])
uploadProgress.value[progressKey].progress = Math.min(
99,
Math.round(((offset + event.loaded) / Math.max(1, file.size)) * 100),
);
};
xhr.onload = () => {
finish();
let payload;
try {
message = JSON.parse(xhr.responseText).message;
} catch {}
uploadProgress.value[progressKey] = {
name: file.name,
progress: 0,
status: 'failed',
accountId,
message,
};
reject(Error(message));
payload = JSON.parse(xhr.responseText);
} catch {
payload = {};
}
if (xhr.status >= 200 && xhr.status < 300) resolve(payload);
else
reject(
Object.assign(Error(payload.message || '파일 조각을 보내지 못했습니다.'), {
status: xhr.status,
code: payload.code,
}),
);
};
xhr.onerror = xhr.onabort = () => {
finish();
reject(Object.assign(Error('업로드 연결이 중단되었습니다.'), { status: 0 }));
};
xhr.send(file.slice(offset, offset + chunkBytes));
});
const retryable = (error) =>
error.status === 0 ||
error.status >= 500 ||
[408, 429].includes(error.status) ||
['UPLOAD_OFFSET', 'UPLOAD_ACTIVE'].includes(error.code);
try {
const fingerprint = await fileFingerprint(file, controller.signal);
check();
let state = await api(
'/uploads/resumable',
'POST',
{ requestId: id, fileIndex: index, fingerprint },
undefined,
controller.signal,
);
let failures = 0;
while (state.offset < file.size) {
check();
try {
state = await sendChunk(state.uploadId, state.offset);
failures = 0;
} catch (error) {
check();
if (!retryable(error) || ++failures > 3) throw error;
await new Promise((resolve) => setTimeout(resolve, failures * 400));
check();
state = await api(
`/uploads/resumable/${state.uploadId}`,
'GET',
undefined,
undefined,
controller.signal,
);
}
}
check();
if (state.status !== 'stored') {
try {
await api(
`/uploads/resumable/${state.uploadId}/complete`,
'POST',
{},
undefined,
controller.signal,
);
} catch (error) {
check();
const current = await api(
`/uploads/resumable/${state.uploadId}`,
'GET',
undefined,
undefined,
controller.signal,
);
if (current.status !== 'stored') throw error;
}
}
check();
uploadProgress.value[progressKey] = {
name: file.name,
progress: 100,
status: 'stored',
accountId,
};
xhr.onerror = xhr.onabort = () => {
finish();
if (accountId && account.value?.id !== accountId) {
delete uploadProgress.value[progressKey];
reject(Error('계정 세션이 종료되었습니다.'));
return;
}
} catch (error) {
if (cancelledTransfers.has(id) || (accountId && account.value?.id !== accountId))
delete uploadProgress.value[progressKey];
else
uploadProgress.value[progressKey] = {
name: file.name,
progress: 0,
status: 'failed',
accountId,
message: '업로드가 중단되었습니다.',
message: error.message || '업로드가 중단되었습니다.',
};
reject(Error('업로드가 중단되었습니다.'));
};
const form = new FormData();
form.append('file', file);
xhr.send(form);
});
throw error;
} finally {
uploadRequests.delete(progressKey);
}
}
async function deliverFiles(id) {
if (fileSending.has(id)) return;
@@ -317,6 +409,12 @@ async function deliverFiles(id) {
}
if (requests.value.find((r) => r.id === id)?.status !== 'accepted') break;
}
if (
Object.entries(uploadProgress.value).some(
([key, value]) => key.startsWith(`${id}:`) && value.status === 'failed',
)
)
return;
await refresh();
if (requests.value.find((r) => r.id === id)?.transferStatus === 'stored') fileDrafts.delete(id);
} finally {

View File

@@ -4,3 +4,8 @@ import App from './App.vue';
import './style.css';
applyTheme(initialTheme());
createApp(App).mount('#app');
if (import.meta.env.PROD && window.isSecureContext && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
void navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).catch(() => {});
});
}

View File

@@ -2932,6 +2932,46 @@ a.button {
align-items: flex-end;
gap: 18px;
}
.pwa-install-button {
display: flex;
align-items: center;
gap: 5px;
border: 0;
background: none;
color: var(--muted);
font-size: 11px;
padding: 8px 0;
min-height: 36px;
white-space: nowrap;
}
.install-help p {
margin: 28px 0 18px;
line-height: 1.9;
font-size: 14px;
}
.install-help small {
color: var(--muted);
font-size: 12px;
}
.discovery-status {
display: flex;
justify-content: center;
align-items: center;
gap: 7px;
color: var(--muted);
font-size: 11px;
margin-top: 12px;
}
@media (max-width: 360px) {
.header-tagline {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.discovery-status .spin {
animation: none;
}
}
.header-tagline {
color: var(--muted);
font-size: 13px;