Add resumable uploads, proxy discovery, PWA install and 2 GiB personal vaults
This commit is contained in:
@@ -11,3 +11,4 @@ playwright-report
|
||||
tests/e2e/*.png
|
||||
compose.nas.yaml
|
||||
backups
|
||||
hop.proxy.conf
|
||||
|
||||
@@ -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. <hello@noname.company>
|
||||
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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ test-results/
|
||||
tests/e2e/*.png
|
||||
compose.nas.yaml
|
||||
backups/
|
||||
hop.proxy.conf
|
||||
|
||||
@@ -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이며 화면에는 내역을 표시하지 않습니다.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
18
apps/server/src/check-proxy.js
Normal file
18
apps/server/src/check-proxy.js
Normal 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');
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
252
apps/server/src/resumable.js
Normal file
252
apps/server/src/resumable.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
74
apps/server/test/cloudflare-discovery.test.js
Normal file
74
apps/server/test/cloudflare-discovery.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
182
apps/server/test/resumable.test.js
Normal file
182
apps/server/test/resumable.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
apps/web/public/icon-192.png
Normal file
BIN
apps/web/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/icon-maskable.png
Normal file
BIN
apps/web/public/icon-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
17
apps/web/public/manifest.webmanifest
Normal file
17
apps/web/public/manifest.webmanifest
Normal 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
18
apps/web/public/sw.js
Normal 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' },
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -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)">
|
||||
|
||||
@@ -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의
|
||||
|
||||
94
apps/web/src/InstallButton.vue
Normal file
94
apps/web/src/InstallButton.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
14
compose.yaml
14
compose.yaml
@@ -1,6 +1,11 @@
|
||||
services:
|
||||
hop:
|
||||
build: .
|
||||
image: hop.noname.company:latest
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: hop
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
@@ -13,6 +18,8 @@ services:
|
||||
volumes:
|
||||
- hop_database:/data/database
|
||||
- hop_uploads:/data/uploads
|
||||
networks:
|
||||
- hop_network
|
||||
stop_grace_period: 30s
|
||||
logging:
|
||||
driver: json-file
|
||||
@@ -21,4 +28,9 @@ services:
|
||||
max-file: '3'
|
||||
volumes:
|
||||
hop_database:
|
||||
name: hop_hop_database
|
||||
hop_uploads:
|
||||
name: hop_hop_uploads
|
||||
networks:
|
||||
hop_network:
|
||||
driver: bridge
|
||||
|
||||
104
deploy.md
104
deploy.md
@@ -6,6 +6,10 @@
|
||||
|
||||
아래 코드 블록을 하나씩 복사하고 Enter를 누르세요. 오류가 나오면 다음 단계로 넘어가지 마세요.
|
||||
|
||||
이미 배포했다면 **7. 업데이트**부터 진행하세요. 이번 업데이트는 **8MiB 분할 업로드·주변 자동 발견·PWA 설치·개인 보관함 2GiB**를 추가하며 기존 DB·업로드 볼륨을 유지합니다. 새 도메인이나 외부 포트는 추가하지 않습니다.
|
||||
|
||||
**‘허용되지 않은 접속 경로’가 나오면:** 주소창을 `https://hop.noname.company`로 바꾸세요. `http://`는 운영 `APP_URL`과 달라 차단됩니다. 5-1단계에서 HTTP를 HTTPS로 자동 이동시킵니다.
|
||||
|
||||
## 1. SSH에서 소스 받기
|
||||
|
||||
NAS에서 프로젝트를 둘 상위 폴더까지 이동한 상태에서 실행합니다.
|
||||
@@ -84,15 +88,20 @@ sh scripts/configure-nas.sh
|
||||
| Cache Assets | 끄기 |
|
||||
| Websockets Support | 켜기 |
|
||||
|
||||
3. **Advanced**에 다음 내용 입력. 동일한 설정이 있으면 중복으로 넣지 말고 기존 값을 수정합니다.
|
||||
3. SSH에서 다음 명령을 실행합니다. 사용자 NAS의 실제 `cloudflared-tunnel` 네트워크 주소로 hop. 전용 설정을 만듭니다.
|
||||
|
||||
```nginx
|
||||
client_max_body_size 2048m;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
```sh
|
||||
sh scripts/print-hop-proxy-config.sh > hop.proxy.conf
|
||||
```
|
||||
|
||||
4. **Save** 누르기.
|
||||
```sh
|
||||
cat hop.proxy.conf
|
||||
```
|
||||
|
||||
4. 나온 내용을 **hop.noname.company의 Advanced**에 붙여넣습니다. 기존의 같은 세 항목은 출력된 내용으로 교체합니다. 다른 도메인의 설정은 바꾸지 않습니다.
|
||||
5. **Save** 누르기.
|
||||
|
||||
이 설정은 Tunnel이 연결하는 Docker 주소를 허용하고 외부에서 원본 IP 헤더를 흉내 내어 들어오는 경로를 차단합니다. 실제 전달 헤더는 [Cloudflare의 CF-Connecting-IP](https://developers.cloudflare.com/fundamentals/reference/http-headers/)를 사용합니다. `nginx-proxy-manager-app-1`과 `cloudflared-tunnel`의 이름은 현재 NAS에서 확인한 값입니다. 호스트 네트워크 모드라 네트워크 주소를 얻을 수 없으면 설정을 넓히지 말고 오류를 알려주세요.
|
||||
|
||||
NPM이 Docker 컨테이너라면 Forward IP에 `127.0.0.1`을 입력하지 마세요. 여기서는 **NAS 내부 IP**를 사용합니다. 기존 Tunnel이 NPM의 8080으로 들어오는 설정은 그대로 유지합니다. NPM에 별도 인증서나 Force SSL을 추가하는 단계는 필요하지 않습니다. 공개 HTTPS는 현재 Cloudflare 경로를 사용합니다. NPM은 [공식 문서](https://nginxproxymanager.com/guide/)의 도메인별 전달 기능을 사용합니다.
|
||||
|
||||
@@ -106,6 +115,49 @@ NPM이 Docker 컨테이너라면 Forward IP에 `127.0.0.1`을 입력하지 마
|
||||
|
||||
8080을 50189로 바꾸지 않습니다. Tunnel은 NPM으로, NPM의 hop. 항목은 50189로 전달합니다.
|
||||
|
||||
## 5-1. HTTP를 HTTPS로 자동 이동
|
||||
|
||||
1. Cloudflare → `noname.company` → **Rules → Redirect Rules → Create rule**.
|
||||
2. 이름: `hop HTTPS`.
|
||||
3. **Custom filter expression**에 입력:
|
||||
|
||||
```text
|
||||
(http.host eq "hop.noname.company" and http.request.scheme eq "http")
|
||||
```
|
||||
|
||||
4. URL redirect 타입: **Dynamic**.
|
||||
5. Target URL expression:
|
||||
|
||||
```text
|
||||
concat("https://hop.noname.company", http.request.uri.path)
|
||||
```
|
||||
|
||||
6. 상태 코드 **301**, **Preserve query string** 켜기 → **Deploy**.
|
||||
7. `http://hop.noname.company`를 열어 주소창이 `https://hop.noname.company`로 바뀌는지 확인.
|
||||
|
||||
hop.에만 적용합니다. Tunnel → NPM 구간을 HTTP로 사용하는 기존 설정은 유지합니다. [Cloudflare 공식 리디렉션 설정](https://developers.cloudflare.com/rules/url-forwarding/single-redirects/create-dashboard/).
|
||||
|
||||
## 5-2. 주변 자동 발견 활성화
|
||||
|
||||
새 버전의 컨테이너가 실행 중이고 4단계 NPM 설정을 저장한 뒤, NAS의 hop 폴더에서 실행합니다.
|
||||
|
||||
```sh
|
||||
sh scripts/enable-nas-discovery.sh
|
||||
```
|
||||
|
||||
이 명령은 **현재 운영 주소를 통한 실제 요청**으로 인접 프록시와 Cloudflare 원본 IP 전달을 확인하고, 그 프록시 IP만 `.env`에 지정합니다. 운영 진단 토큰은 설정 스크립트가 생성하며 화면이나 Git에 공개하지 않습니다.
|
||||
|
||||
1. 명령이 성공하면 Docker 앱 → **프로젝트 → hop → 편집**.
|
||||
2. **compose.nas.yaml** 다시 불러오기 → 저장.
|
||||
3. **재배포**하여 컨테이너를 새 환경설정으로 다시 생성. 단순 재시작은 환경값을 다시 읽지 않습니다.
|
||||
4. PC와 휴대폰을 같은 Wi-Fi에 연결하고 `https://hop.noname.company`를 열기.
|
||||
5. QR 없이 상대 기기가 표시되는지 확인.
|
||||
6. 휴대폰 Wi-Fi를 끄고 모바일 데이터로 전환 → 다른 네트워크 기기가 목록에서 사라지는지 확인.
|
||||
|
||||
확인 중 오류가 나면 자동 발견 설정을 임의로 넓히지 마세요. 오류와 Docker 로그를 알려주세요. 주변 목록은 같은 **공인 IP** 기준이며 Bluetooth 탐색은 아닙니다. IPv6 주소가 서로 다르거나 VPN을 사용하면 같은 Wi-Fi에서도 자동 목록이 다를 수 있습니다.
|
||||
|
||||
NPM 또는 Tunnel 컨테이너를 재생성해 Docker IP가 바뀌었다면 4단계 설정을 다시 출력하여 저장한 뒤 5-2단계를 다시 실행합니다.
|
||||
|
||||
## 6. 동작 확인
|
||||
|
||||
브라우저에서 다음 주소를 엽니다.
|
||||
@@ -114,24 +166,48 @@ NPM이 Docker 컨테이너라면 Forward IP에 `127.0.0.1`을 입력하지 마
|
||||
https://hop.noname.company
|
||||
```
|
||||
|
||||
1. PC에서 접속.
|
||||
2. **QR로 연결**을 눌러 연결 공간 만들기.
|
||||
3. 휴대폰에서 QR을 스캔하거나 다른 브라우저에서 연결 코드 입력.
|
||||
1. PC와 휴대폰을 같은 Wi-Fi에서 열어 주변 기기가 자동으로 보이는지 확인.
|
||||
2. 다른 네트워크의 기기는 **QR로 연결**을 눌러 연결 공간 만들기.
|
||||
3. 해당 기기에서 QR을 스캔하거나 연결 코드 입력.
|
||||
4. 참여 후 QR 모달이 닫히고 상대 기기가 보이는지 확인.
|
||||
5. 상대 기기 선택 → 링크 전송 → 상대에서 수락 → 링크가 열리는지 확인.
|
||||
6. 작은 파일 전송 → 상대에서 수락 → 다운로드 확인.
|
||||
7. 가입을 활성화했다면 **로그인 → 회원가입**에서 실제 이메일로 가입 → Resend 인증 메일 확인 → 로그인.
|
||||
8. 로그인 상태로 받은 자료가 **보관함 → 받은 항목**에 표시되는지 확인.
|
||||
|
||||
처음 배포는 **QR·코드 연결**로 확인합니다. 주변 자동 발견은 실제 클라이언트 IP 전달과 프록시 신뢰 범위를 확인한 다음 활성화해야 합니다. 현재 설정에서는 꺼져 있습니다. 임의로 `TRUSTED_PROXY_CIDRS`에 모든 IP를 넣지 마세요.
|
||||
주변 자동 발견은 **5-2단계를 실행해 활성화**합니다. 임의로 `TRUSTED_PROXY_CIDRS`에 모든 IP를 넣지 마세요.
|
||||
|
||||
운영 비밀번호는 **12자 이상**입니다. 개발 DB와 `1234` 테스트 계정은 NAS에 복사되지 않습니다.
|
||||
|
||||
**대용량 파일:** Cloudflare를 통과하는 업로드에는 요금제 제한이 있습니다. 현재 공식 안내는 Free/Pro **100MB**, Business **200MB**입니다. NPM의 `2048m` 설정으로 이 제한이 늘어나지는 않습니다. 큰 파일에서 413이 나오면 [Cloudflare 업로드 제한](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-413/)을 확인하세요. 현재 hop.은 파일을 작은 요청으로 나누어 업로드하지 않습니다.
|
||||
모바일에서는 상단 **설치** 버튼을 누릅니다. Android 등 지원 브라우저는 설치 창이 열리고, iPhone은 Safari의 **공유 → 홈 화면에 추가** 안내가 열립니다. 설치한 앱 안에서는 설치 버튼을 숨깁니다. 전송은 앱을 열고 인터넷에 연결된 상태에서 합니다. PWA는 파일·전송 기록·API 응답을 오프라인 캐시에 저장하지 않습니다.
|
||||
|
||||
### 용량 설정
|
||||
|
||||
환경변수의 MB는 MiB 기준입니다. `2048`은 2GiB입니다.
|
||||
|
||||
| 설정 | 기준 |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| `MAX_FILE_SIZE_MB=2048` | 파일 한 개당 최대 2GiB |
|
||||
| `USER_STORAGE_LIMIT_MB=2048` | 로그인 계정 **개인별** 보관함 2GiB. 임시 저장·받은 파일 합산, 같은 원본은 한 계정에서 한 번 계산 |
|
||||
| `GUEST_STORAGE_LIMIT_MB=2048` | 비로그인 기기 세션이 서버에 올리는 파일 총량 2GiB. 로그인 후 기기 간 전송도 해당 기기 세션을 사용 |
|
||||
| `TOTAL_GUEST_STORAGE_LIMIT_MB=20480` | 서버 전체 익명 업로드 원본의 합계 20GiB |
|
||||
| `MIN_FREE_DISK_MB=5120` | NAS에 최소 5GiB 여유 공간 확보 |
|
||||
| `DEFAULT_FILE_RETENTION_DAYS=7` | 정상 저장한 파일 최대 7일 후 삭제 |
|
||||
| `ORPHAN_UPLOAD_RETENTION_HOURS=24` | 중단·방치한 업로드 조각 정리 기준 24시간 |
|
||||
|
||||
비로그인은 내역 화면이 없습니다. 파일은 서버로 업로드한 뒤 상대가 다운로드하므로, 전송과 정리를 위한 임시 서버 파일은 필요합니다. 로그인한 개인 보관함과 익명 전체 용량은 별도입니다. 직접 보관함 파일을 삭제하거나 만료되면 개인 사용량에서 빠지며, 다른 사람에게 공유된 원본은 마지막 유효 접근권이 끝날 때 물리적으로 삭제됩니다.
|
||||
|
||||
개인 한도가 차도 **수신·다운로드는 계속**합니다. 새로 받은 파일은 받은 항목에 저장하지 않고 짧은 안내를 표시합니다. 직접 저장은 용량 부족 시 거절합니다. 링크·메모는 파일 용량 합계에 포함하지 않습니다. 기존 10GiB 기본값은 설정 스크립트가 2GiB로 바꾸며, 다른 값으로 조정한 설정과 기존 자료는 유지합니다.
|
||||
|
||||
**대용량 파일:** hop.은 파일을 **8MiB씩 분할**해 업로드합니다. Cloudflare의 Free/Pro **100MB**, Business **200MB** 제한은 **HTTP 요청 한 번의 크기**이므로 전체 파일을 그 크기로 제한하지 않습니다. 앱의 파일당 최대 **2GiB**와 계정·익명 저장소 제한은 유지합니다. 중간 요청 오류는 서버가 받은 위치부터 최대 3회 자동 재시도합니다. 재시도가 끝나면 현재 전송 화면의 ‘다시 전송’을 누릅니다. 브라우저를 재시작하거나 로그아웃한 뒤의 자동 복구는 보장하지 않습니다. 마지막 파일 내용 검증과 저장이 끝나야 수신 자료가 생성됩니다. [Cloudflare 업로드 제한](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-413/).
|
||||
|
||||
## 7. 다음 버전으로 업데이트
|
||||
|
||||
SSH에서 같은 hop 폴더까지 이동한 다음 진행합니다.
|
||||
사용자가 확인한 NAS 경로에서 진행합니다.
|
||||
|
||||
```sh
|
||||
cd /volume1/docker/projects/apps/hop
|
||||
```
|
||||
|
||||
1. Docker 앱 → **프로젝트 → hop → 중지**.
|
||||
2. SSH에서 데이터 백업:
|
||||
@@ -171,7 +247,9 @@ sh scripts/configure-nas.sh
|
||||
5. Docker 앱 → **프로젝트 → hop → 편집**.
|
||||
6. 최신 **compose.nas.yaml**을 다시 불러오거나 편집기에 붙여넣기 → 저장.
|
||||
7. **재빌드 / 이미지 다시 빌드** 선택 → **시작 / 재배포**. 단순 재시작만 하면 바뀐 코드가 이미지에 반영되지 않습니다.
|
||||
8. `healthy` 확인 → 6단계의 링크·파일 전송 확인.
|
||||
8. `healthy` 확인.
|
||||
9. 이번 업데이트에서는 **4단계**의 hop. 전용 NPM 설정, **5-1단계** HTTPS 자동 이동, **5-2단계** 자동 발견 활성화를 순서대로 진행.
|
||||
10. 브라우저 새로고침 → 6단계의 링크·100MB보다 큰 파일 전송 확인.
|
||||
|
||||
프로젝트를 삭제하거나 `docker compose down -v`를 실행하지 마세요. DB·파일은 `hop_hop_database`, `hop_hop_uploads` 볼륨에 남습니다. 업데이트 실패 시 중지하고 오류를 확인하세요. DB 마이그레이션이 적용됐을 수 있으므로 코드만 되돌리지 말고 같은 시점의 DB·업로드·환경파일 백업으로 함께 복구해야 합니다.
|
||||
|
||||
|
||||
@@ -41,3 +41,11 @@
|
||||
마이그레이션 0007–0011은 계정 수신 연결·개인 메모·7일 제한·단일 방 정리·활성 참여의 DB 유일 제약을 추가한다. 기존 다중 참여는 가장 최근 참여 하나만 남기고 방장이 없는 방은 닫는다. 원래 마이그레이션 기록·자료 ID·상대 접근권은 보존한다. 직접 저장의 기존 내부 self 요청은 기존 저장 흐름과 마이그레이션 호환성을 위해 내부 `email` 모드를 재사용하며, 외부 이메일 전송을 다시 열지는 않는다.
|
||||
|
||||
개발은 자동 발견과 반복 테스트용 요청 한도를 사용하고 포트는 OS가 배정한다. 운영의 실제 포트·바인딩·프록시 설정은 배포 때 사용자에게 확인한다.
|
||||
|
||||
## 2026-10-12 업데이트
|
||||
|
||||
- 개인 보관함 기본 한도 2GiB: 임시 저장·받은 항목의 유효한 파일 원본을 계정별로 중복 없이 합산. 받은 파일도 포함하고 삭제·만료한 접근권은 개인 사용량에서 제외한다. 다른 사람의 원본과 저장소를 물리적으로 공유하더라도 계정별 한도는 분리한다.
|
||||
- 개인 한도 초과 시 기기 수신·다운로드는 계속하고 해당 파일의 계정 수신 기록만 생략한다. 짧은 알림을 표시한다. 업로드 중인 개인 저장의 예약과 삭제 복원도 한도를 검사한다. 익명 파일 저장 한도는 개인 보관함과 별도이며 익명 화면의 내역은 계속 숨긴다.
|
||||
- 파일은 8MiB 조각으로 전송하며 같은 파일 내용 검증·이미 받은 위치·중복 조각·서버 재시작을 처리한다. 자동 재시도 실패 시 기존 단일 버튼이 다시 전송으로 바뀐다. 완료 전후 취소와 접근 범위 검사는 유지한다.
|
||||
- 운영 자동 발견은 신뢰한 인접 프록시에서 온 CF-Connecting-IP만 사용하고, 원본 IP와 프록시 진단 결과는 일반 사용자에게 공개하지 않는다. 같은 공인 IP를 계속 탐색한다는 상태를 표시한다.
|
||||
- PWA 설치: Android 등 설치 이벤트 지원 브라우저는 설치 창, iPhone은 홈 화면 추가 안내. 설치 앱 안에서는 버튼을 숨긴다. Service Worker는 민감한 자료나 API를 캐시하지 않고 오프라인에서는 인터넷 연결 안내만 제공한다.
|
||||
|
||||
@@ -78,3 +78,12 @@
|
||||
- 전체 빌드·남은 서버/공통 모듈 타입 검사 성공. API 회귀 59개 성공. 익명 전송·계정/보관함·기기 관리·레이아웃의 Chromium 스위트 성공.
|
||||
- `hop-temporary-vault-check:local` 운영 이미지 빌드와 격리 컨테이너의 마이그레이션·Secure 쿠키·세션/이름/실제 파일 재시작 보존 검증. 호스트 포트는 공개하지 않음.
|
||||
- 실제 Resend 수신, 실물 iOS/Android의 브라우저별 권한, 운영 NAS·프록시·실제 LAN 경로는 이번 자동 검증에 포함하지 않음.
|
||||
|
||||
## NAS 업데이트·분할 업로드·PWA·개인 한도 — 2026-10-12
|
||||
|
||||
- API 테스트 67개 통과. 신뢰한 프록시의 CF-Connecting-IP 기준 자동 발견, 실제 소켓의 같은/다른 공인 IP 격리와 발견 해제, 보호된 운영 진단을 검증했다.
|
||||
- 8MiB 조각의 수락·발신자·Origin·CSRF 검사, 중복·순서·전체 내용 검증, 이어 받는 위치와 중단 쓰기 복구·만료·취소 정리 검증. 기존 업로드 API는 유지했다.
|
||||
- Chromium의 익명 전송·계정 보관함·기기 메뉴·레이아웃·대용량 분할·PWA 스위트 통과. 실제 101MiB 파일을 전송하고 응답 유실·재시도 소진 후 같은 버튼에서 이어 보내기·수락 후 자동 다운로드·최종 바이트를 확인했다.
|
||||
- PWA manifest·PNG 아이콘·실제 Service Worker·iPhone 설치 안내·설치 이벤트·설치 후 숨김·320px 헤더·오프라인 안내·민감한 데이터 캐시 없음 검증. 실제 OS 설치와 실물 Safari/Android는 사용자 기기에서 확인해야 한다.
|
||||
- 개인 보관함 2GiB의 직접 저장·받은 파일 합산, 다른 계정 격리, 한도 초과 수신의 다운로드 유지·계정 기록 생략, 삭제 후 공간 회수·초과 복원 거절을 확인했다.
|
||||
- 전체 빌드와 남은 타입 검사 통과. 운영 Docker 이미지 빌드와 재시작 후 SQLite integrity·세션·기기명·기존 파일·미완료 업로드 조각의 위치 보존 및 이어 보내기 완료 검증. 실제 NAS·Cloudflare·NPM 경로 활성화는 deploy.md의 운영 진단으로 수행한다.
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"format": "prettier --write apps packages scripts tests compose.yaml Dockerfile package.json tsconfig.base.json docs README.md .env.example --ignore-unknown",
|
||||
"test:e2e:account": "node --import tsx tests/e2e/phase3.mjs",
|
||||
"test:e2e:devices": "node --import tsx tests/e2e/device-menu.mjs",
|
||||
"test:e2e:layout": "node --import tsx tests/e2e/layout.mjs"
|
||||
"test:e2e:layout": "node --import tsx tests/e2e/layout.mjs",
|
||||
"test:e2e:chunks": "node --import tsx tests/e2e/chunks.mjs",
|
||||
"test:e2e:pwa": "node --import tsx tests/e2e/pwa.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
|
||||
@@ -79,6 +79,22 @@ else
|
||||
fi
|
||||
|
||||
chmod 600 .env
|
||||
if grep -Eq '^USER_STORAGE_LIMIT_MB=10240$' .env; then
|
||||
hop_env_tmp=$(mktemp .env.nas.XXXXXX)
|
||||
awk '{ if ($0 == "USER_STORAGE_LIMIT_MB=10240") print "USER_STORAGE_LIMIT_MB=2048"; else print }' .env > "$hop_env_tmp"
|
||||
mv "$hop_env_tmp" .env
|
||||
hop_env_tmp=''
|
||||
printf '%s\n' '개인 보관함 기본 한도를 10GiB에서 2GiB로 변경했습니다. 기존 자료는 유지합니다.'
|
||||
fi
|
||||
if ! grep -Eq '^PROXY_DIAGNOSTIC_TOKEN=.{32,}$' .env; then
|
||||
hop_diagnostic_token=$(openssl rand -hex 32)
|
||||
hop_env_tmp=$(mktemp .env.nas.XXXXXX)
|
||||
awk '!/^PROXY_DIAGNOSTIC_TOKEN=/' .env > "$hop_env_tmp"
|
||||
printf 'PROXY_DIAGNOSTIC_TOKEN=%s\n' "$hop_diagnostic_token" >> "$hop_env_tmp"
|
||||
mv "$hop_env_tmp" .env
|
||||
hop_env_tmp=''
|
||||
unset hop_diagnostic_token
|
||||
fi
|
||||
hop_yaml_tmp=$(mktemp .compose.nas.XXXXXX)
|
||||
docker compose --project-name hop --project-directory "$PWD" --env-file .env -f compose.yaml config --no-env-resolution > "$hop_yaml_tmp"
|
||||
mv "$hop_yaml_tmp" compose.nas.yaml
|
||||
|
||||
23
scripts/enable-nas-discovery.sh
Normal file
23
scripts/enable-nas-discovery.sh
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
umask 077
|
||||
hop_settings=$(mktemp .env.discovery.XXXXXX)
|
||||
hop_environment=$(mktemp .env.discovery.XXXXXX)
|
||||
trap 'rm -f "$hop_settings" "$hop_environment"' 0
|
||||
hop_container=$(sudo docker compose --project-name hop -f compose.nas.yaml ps -q hop)
|
||||
if [ -z "$hop_container" ]; then
|
||||
printf '%s\n' 'Docker 앱에서 새 버전의 hop 프로젝트를 먼저 시작하세요.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sudo docker exec "$hop_container" node apps/server/dist/check-proxy.js > "$hop_settings"
|
||||
if [ "$(wc -l < "$hop_settings" | tr -d ' ')" != 3 ]; then
|
||||
printf '%s\n' '프록시 확인 결과가 올바르지 않습니다.' >&2
|
||||
exit 1
|
||||
fi
|
||||
awk '!/^(TRUSTED_PROXY_CIDRS|CLIENT_IP_SOURCE|SAME_NETWORK_DISCOVERY_ENABLED)=/' .env > "$hop_environment"
|
||||
cat "$hop_settings" >> "$hop_environment"
|
||||
mv "$hop_environment" .env
|
||||
chmod 600 .env
|
||||
sh scripts/configure-nas.sh
|
||||
printf '\n%s\n' '자동 발견 설정 완료. Docker 앱에서 compose.nas.yaml을 다시 불러와 재배포하세요.'
|
||||
14
scripts/print-hop-proxy-config.sh
Normal file
14
scripts/print-hop-proxy-config.sh
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
hop_tunnel_peers=$(sudo docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{"\n"}}{{.Gateway}}{{"\n"}}{{.GlobalIPv6Address}}{{"\n"}}{{.IPv6Gateway}}{{"\n"}}{{end}}' cloudflared-tunnel | awk 'NF { print }' | sort -u)
|
||||
hop_proxy_gateways=$(sudo docker inspect --format '{{range .NetworkSettings.Networks}}{{.Gateway}}{{"\n"}}{{.IPv6Gateway}}{{"\n"}}{{end}}' nginx-proxy-manager-app-1 | awk 'NF { print }' | sort -u)
|
||||
if [ -z "$hop_tunnel_peers" ]; then
|
||||
printf '%s\n' 'Tunnel의 Docker 네트워크 주소를 확인할 수 없습니다. 네트워크 설정을 먼저 확인하세요.' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' 'client_max_body_size 2048m;' 'proxy_read_timeout 3600s;' 'proxy_send_timeout 3600s;' 'set $hop_tunnel_allowed 0;'
|
||||
for hop_peer in $hop_tunnel_peers $hop_proxy_gateways; do
|
||||
printf 'if ($realip_remote_addr = "%s") { set $hop_tunnel_allowed 1; }\n' "$hop_peer"
|
||||
done
|
||||
printf '%s\n' 'if ($hop_tunnel_allowed = 0) { return 403; }'
|
||||
@@ -47,7 +47,7 @@ try {
|
||||
);
|
||||
const fileState = JSON.parse(
|
||||
node(
|
||||
`import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId}));`,
|
||||
`import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];const {createHash}=await import('node:crypto');const chunkSize=8*1024*1024,big=Buffer.alloc(chunkSize+7,120),digest=v=>createHash('sha256').update(v).digest();const fingerprint=digest(Buffer.concat([digest(big.subarray(0,chunkSize)),digest(big.subarray(chunkSize))])).toString('hex');const pending=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:big.length,count:1,files:[{name:'resume.bin',size:big.length,type:'application/octet-stream'}]},randomUUID());await call(b,'/api/transfer-requests/'+pending.requestId+'/accept',{},randomUUID());const staged=await call(a,'/api/uploads/resumable',{requestId:pending.requestId,fileIndex:0,fingerprint});const first=await fetch(base+'/api/uploads/resumable/'+staged.uploadId,{method:'PUT',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Content-Type':'application/octet-stream','Upload-Offset':'0'},body:big.subarray(0,chunkSize)});if(!first.ok)throw Error(await first.text());for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId,pending:{uploadId:staged.uploadId,cookie:a.cookie,csrf:a.csrfToken}}));`,
|
||||
),
|
||||
);
|
||||
docker('restart', name);
|
||||
@@ -63,13 +63,17 @@ try {
|
||||
`const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('download failed');console.log(await r.text());`,
|
||||
);
|
||||
assert.equal(restored, 'Docker file persistence');
|
||||
const resumed = node(
|
||||
`import {createHash} from 'node:crypto';const state=${JSON.stringify(fileState.pending)},base='http://127.0.0.1:${port}',path='/api/uploads/resumable/'+state.uploadId;const headers={Origin:'https://hop.test',Cookie:state.cookie,'X-CSRF-Token':state.csrf};const r=await fetch(base+path,{headers});const progress=await r.json();if(!r.ok||progress.offset!==8*1024*1024)throw Error('partial offset not preserved');const last=await fetch(base+path,{method:'PUT',headers:{...headers,'Content-Type':'application/octet-stream','Upload-Offset':String(progress.offset)},body:Buffer.alloc(7,120)});if(!last.ok)throw Error(await last.text());const complete=await fetch(base+path+'/complete',{method:'POST',headers:{...headers,'Content-Type':'application/json'},body:'{}'});if(!complete.ok)throw Error(await complete.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});const item=(await list.json()).items.find(i=>i.file?.name==='resume.bin');const downloaded=await fetch(base+'/api/access/'+item.accessId+'/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});const content=Buffer.from(await downloaded.arrayBuffer());if(!downloaded.ok||content.length!==8*1024*1024+7||!content.equals(Buffer.alloc(content.length,120)))throw Error('resumed content mismatch');console.log('resumed');`,
|
||||
);
|
||||
assert.equal(resumed, 'resumed');
|
||||
const integrity = node(
|
||||
`import Database from 'better-sqlite3';const db=new Database('/data/database/hop.sqlite');console.log(db.pragma('integrity_check',{simple:true}));db.close();`,
|
||||
);
|
||||
assert.equal(integrity, 'ok');
|
||||
assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}');
|
||||
console.log(
|
||||
'PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file persistence after restart, SQLite integrity, no host ports published.',
|
||||
'PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file persistence and partial chunk resume after restart, SQLite integrity, no host ports published.',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' }));
|
||||
|
||||
106
tests/e2e/chunks.mjs
Normal file
106
tests/e2e/chunks.mjs
Normal file
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from '@playwright/test';
|
||||
import { mkdtemp, open, rm, stat, readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createApp } from '../../apps/server/src/app.ts';
|
||||
import { loadConfig } from '../../apps/server/src/config.ts';
|
||||
const folder = await mkdtemp(resolve(tmpdir(), 'hop-chunks-'));
|
||||
const runtime = await createApp({
|
||||
...loadConfig({
|
||||
APP_URL: 'http://127.0.0.1',
|
||||
DATABASE_PATH: resolve(folder, 'db.sqlite'),
|
||||
UPLOAD_DIR: resolve(folder, 'uploads'),
|
||||
MIN_FREE_DISK_MB: '0',
|
||||
SESSION_SECRET: 's'.repeat(40),
|
||||
DISCOVERY_SECRET: 'd'.repeat(40),
|
||||
}),
|
||||
webRoot: resolve('apps/web/dist'),
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const url = `http://127.0.0.1:${runtime.app.server.address().port}`;
|
||||
runtime.setDevOrigin(url);
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.HOP_BROWSER_EXECUTABLE
|
||||
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
|
||||
: {}),
|
||||
});
|
||||
const a = await browser.newContext(),
|
||||
b = await browser.newContext();
|
||||
const sender = await a.newPage(),
|
||||
receiver = await b.newPage(),
|
||||
errors = [];
|
||||
for (const page of [sender, receiver])
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await Promise.all([sender.goto(url), receiver.goto(url)]);
|
||||
await sender.locator('.peer-card').first().waitFor();
|
||||
await receiver.locator('.peer-card').first().waitFor();
|
||||
const size = 101 * 1024 * 1024 + 23,
|
||||
source = resolve(folder, 'over-100MiB.bin');
|
||||
const file = await open(source, 'w');
|
||||
await file.truncate(size);
|
||||
await file.write(Buffer.from('actual large transfer'), 0, 21, size - 21);
|
||||
await file.close();
|
||||
let lostReply = false;
|
||||
let failedAttempts = 0,
|
||||
allowRetry = false;
|
||||
const sizes = [],
|
||||
offsets = [];
|
||||
await sender.route('**/api/uploads/resumable/*', async (route) => {
|
||||
if (route.request().method() !== 'PUT') return route.continue();
|
||||
const bytes = route.request().postDataBuffer();
|
||||
if (lostReply && !allowRetry && failedAttempts < 3) {
|
||||
failedAttempts++;
|
||||
return route.abort('failed');
|
||||
}
|
||||
sizes.push(bytes.length);
|
||||
offsets.push(Number(route.request().headers()['upload-offset']));
|
||||
// Emulate a proxy with a per-request cap, and lose an acknowledged chunk response.
|
||||
if (bytes.length > 9 * 1024 * 1024) return route.fulfill({ status: 413, body: '{}' });
|
||||
if (!lostReply) {
|
||||
await route.fetch();
|
||||
lostReply = true;
|
||||
return route.abort('failed');
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await sender.locator('.peer-card').first().click();
|
||||
await sender.locator('input[type=file]').setInputFiles(source);
|
||||
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
||||
const downloaded = receiver.waitForEvent('download', { timeout: 120000 });
|
||||
void downloaded.catch(() => {});
|
||||
await receiver.getByRole('button', { name: '수락', exact: true }).click();
|
||||
const retry = sender.getByRole('button', { name: '다시 전송', exact: true });
|
||||
await retry.waitFor({ timeout: 30000 });
|
||||
assert.equal(failedAttempts, 3);
|
||||
allowRetry = true;
|
||||
await retry.click();
|
||||
const download = await downloaded,
|
||||
path = await download.path();
|
||||
assert.equal((await stat(path)).size, size);
|
||||
const handle = await open(path, 'r'),
|
||||
tail = Buffer.alloc(21);
|
||||
await handle.read(tail, 0, 21, size - 21);
|
||||
await handle.close();
|
||||
assert.equal(tail.toString(), 'actual large transfer');
|
||||
assert.equal(sizes.length, 13);
|
||||
assert(sizes.every((value) => value <= 8 * 1024 * 1024));
|
||||
assert.equal(offsets[0], 0);
|
||||
assert.equal(offsets[1], 8 * 1024 * 1024, '응답이 유실돼도 이미 받은 조각을 다시 전송하지 않음');
|
||||
assert.equal(
|
||||
sizes.reduce((sum, value) => sum + value, 0),
|
||||
size,
|
||||
);
|
||||
await sender.locator('.send-dialog').waitFor({ state: 'hidden' });
|
||||
assert.equal(errors.length, 0, errors.join('\n'));
|
||||
console.log(
|
||||
'PASS: 두 실제 브라우저 자동 발견, 101MiB 파일의 8MiB 분할, 요청 크기 제한, 응답 유실 후 이어 보내기, 수락 후 자동 다운로드, 원본 크기·내용 검증',
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await runtime.app.close();
|
||||
await rm(folder, { recursive: true, force: true });
|
||||
}
|
||||
@@ -244,7 +244,8 @@ try {
|
||||
const uploadHeld = new Promise((resolve) => {
|
||||
releaseUpload = resolve;
|
||||
});
|
||||
await sender.route('**/api/uploads?*', async (route) => {
|
||||
await sender.route('**/api/uploads/resumable/*', async (route) => {
|
||||
if (route.request().method() !== 'PUT') return route.continue();
|
||||
uploadStarted();
|
||||
await uploadHeld;
|
||||
try {
|
||||
@@ -265,7 +266,7 @@ try {
|
||||
releaseUpload();
|
||||
await receiver.locator('.receiving-card').waitFor({ state: 'hidden' });
|
||||
await sender.locator('.send-button').filter({ hasText: '전송하기' }).waitFor();
|
||||
await sender.unroute('**/api/uploads?*');
|
||||
await sender.unroute('**/api/uploads/resumable/*');
|
||||
assert.equal(
|
||||
await sender.getByRole('alert').count(),
|
||||
0,
|
||||
|
||||
@@ -183,7 +183,7 @@ try {
|
||||
await sender.locator('.item-card').filter({ hasText: 'saved.txt' }).waitFor();
|
||||
const uploads = [];
|
||||
sender.on('request', (request) => {
|
||||
if (new URL(request.url()).pathname === '/api/uploads') uploads.push(request.url());
|
||||
if (new URL(request.url()).pathname.startsWith('/api/uploads')) uploads.push(request.url());
|
||||
});
|
||||
await navigate(sender, '기기');
|
||||
await sender.locator('.peer-card').first().click();
|
||||
|
||||
86
tests/e2e/pwa.mjs
Normal file
86
tests/e2e/pwa.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from '@playwright/test';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
import { createApp } from '../../apps/server/src/app.ts';
|
||||
import { loadConfig } from '../../apps/server/src/config.ts';
|
||||
const folder = await mkdtemp(resolve(tmpdir(), 'hop-pwa-'));
|
||||
const runtime = await createApp({
|
||||
...loadConfig({
|
||||
APP_URL: 'http://127.0.0.1',
|
||||
DATABASE_PATH: resolve(folder, 'db.sqlite'),
|
||||
UPLOAD_DIR: resolve(folder, 'uploads'),
|
||||
MIN_FREE_DISK_MB: '0',
|
||||
SESSION_SECRET: 's'.repeat(40),
|
||||
DISCOVERY_SECRET: 'd'.repeat(40),
|
||||
}),
|
||||
webRoot: resolve('apps/web/dist'),
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const url = `http://127.0.0.1:${runtime.app.server.address().port}`;
|
||||
runtime.setDevOrigin(url);
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.HOP_BROWSER_EXECUTABLE
|
||||
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
|
||||
: {}),
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.addInitScript(() => {
|
||||
window.addEventListener('beforeinstallprompt', (event) => {
|
||||
if (!event.__hopTest) { event.preventDefault(); event.stopImmediatePropagation(); }
|
||||
}, { capture: true });
|
||||
});
|
||||
await page.goto(url);
|
||||
await page.locator('.app-shell[data-online="true"]').waitFor();
|
||||
const manifest = await (await context.request.get(`${url}/manifest.webmanifest`)).json();
|
||||
assert.equal(manifest.display, 'standalone');
|
||||
assert.equal(manifest.scope, '/');
|
||||
for (const icon of manifest.icons)
|
||||
assert.equal((await context.request.get(url + icon.src)).status(), 200);
|
||||
await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
|
||||
assert.deepEqual(await page.evaluate(() => caches.keys()), []);
|
||||
const install = page.getByRole('button', { name: 'hop. 앱 설치', exact: true });
|
||||
await install.click();
|
||||
await page.getByRole('dialog', { name: '앱 설치 안내' }).waitFor();
|
||||
assert.match(await page.locator('.install-help').innerText(), /Safari.*공유/s);
|
||||
await page.screenshot({ path: 'tests/e2e/pwa-ios.png' });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('dialog', { name: '앱 설치 안내' }).waitFor({ state: 'hidden' });
|
||||
await page.setViewportSize({ width: 320, height: 700 });
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||
await page.evaluate(() => {
|
||||
window.__installCalls = 0;
|
||||
const event = new Event('beforeinstallprompt', { cancelable: true });
|
||||
event.__hopTest = true;
|
||||
event.prompt = async () => {
|
||||
window.__installCalls++;
|
||||
};
|
||||
event.userChoice = Promise.resolve({ outcome: 'accepted' });
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
await install.click();
|
||||
assert.equal(await page.evaluate(() => window.__installCalls), 1);
|
||||
await install.waitFor({ state: 'hidden' });
|
||||
await page.reload();
|
||||
assert.equal(await page.evaluate(async () => (await fetch('/api/device/me')).status), 200);
|
||||
await context.setOffline(true);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
assert.match(await page.locator('body').innerText(), /인터넷 연결이 필요/);
|
||||
assert.deepEqual(await page.evaluate(() => caches.keys()), []);
|
||||
console.log(
|
||||
'PASS: 설치 manifest·아이콘, 실제 Service Worker, iPhone 홈 화면 안내, 설치 이벤트와 설치 후 숨김, 320px 헤더, 온라인 API와 개인정보 캐시 없음, 오프라인 안내',
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await runtime.app.close();
|
||||
await rm(folder, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user