Use visible NAS upload storage and reconcile missing files
This commit is contained in:
@@ -11,4 +11,5 @@ playwright-report
|
||||
tests/e2e/*.png
|
||||
compose.nas.yaml
|
||||
backups
|
||||
uploads
|
||||
hop.proxy.conf
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
HOP_BIND_IP=
|
||||
HOP_HOST_PORT=
|
||||
HOP_CONTAINER_PORT=
|
||||
# NAS 설정 스크립트가 SSH 사용자의 UID/GID를 채웁니다.
|
||||
HOP_RUN_UID=
|
||||
HOP_RUN_GID=
|
||||
APP_URL=https://hop.noname.company
|
||||
NODE_ENV=production
|
||||
TZ=Asia/Seoul
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,4 +11,5 @@ test-results/
|
||||
tests/e2e/*.png
|
||||
compose.nas.yaml
|
||||
backups/
|
||||
/uploads/
|
||||
hop.proxy.conf
|
||||
|
||||
@@ -39,7 +39,7 @@ docker compose config
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
앱 시작 시 버전 관리된 Drizzle 마이그레이션을 적용하며 실패하면 시작하지 않습니다. DB와 업로드 볼륨은 재배포·재시작 후 유지됩니다. `docker compose down -v`는 데이터를 삭제하므로 사용하지 마세요.
|
||||
앱 시작 시 버전 관리된 Drizzle 마이그레이션을 적용하며 실패하면 시작하지 않습니다. DB 볼륨과 프로젝트의 uploads 폴더는 재배포·재시작 후 유지됩니다. `docker compose down -v`는 데이터를 삭제하므로 사용하지 마세요.
|
||||
|
||||
Nginx Proxy Manager에는 실제 컨테이너/호스트 경로의 포트를 지정하고 HTTPS·WebSocket 지원을 켭니다. Secure 쿠키 때문에 운영 환경에서는 HTTPS가 필수입니다. 요청·연결 타임아웃과 업로드 제한은 Phase 2에서 문서화하고 실제 경로에서 검증합니다. 앱의 파일 상한은 중간 프록시·Cloudflare 상한을 보장하지 않습니다.
|
||||
|
||||
@@ -53,6 +53,10 @@ QR·4자리 코드는 기본 10분의 임시 공간 초대입니다. 초대 코
|
||||
|
||||
비로그인 화면에는 전송 내역을 표시하지 않습니다. 수락 후 링크는 새 탭으로 열고 파일은 다운로드합니다. 텍스트는 원문 미리보기와 클립보드 복사 버튼을 표시합니다. 익명 기기 세션은 기본 30일이며 저장한 자료는 최대 7일입니다. 쿠키 삭제·만료·초기화 시 이전 익명 접근권은 복구할 수 없습니다. 로그인 전 수신 자료를 계정으로 가져오지 않습니다. 기기 세션 정리가 필요하면 브라우저의 사이트 데이터를 삭제합니다. 기기 이름은 인증된 신원이 아니며 전송은 서버를 경유합니다. E2EE나 P2P 전송을 제공한다고 안내하지 않습니다.
|
||||
|
||||
## 파일 저장 위치
|
||||
|
||||
NAS의 파일 원본은 `/volume1/docker/projects/apps/hop/uploads/originals`에 저장합니다. 앱은 NAS 사용자의 UID/GID로 실행하며 업로드 폴더는 Git·Docker 빌드에서 제외합니다. DB는 `hop_hop_database` 영구 볼륨에 있습니다. 원본 수동 삭제는 조회·다운로드·전송과 1분 주기 정리 시 감지해 공유된 모든 항목과 용량 계산에 반영합니다. 권한 오류는 삭제로 오인하지 않습니다. 기존 설치의 최초 전환·테스트 자료 초기화는 [deploy.md](deploy.md)의 7-1단계를 진행하고, 이후 일반 업데이트에서는 초기화하지 않습니다.
|
||||
|
||||
## 업데이트와 백업
|
||||
|
||||
일반 코드·UI 업데이트는 `git pull --ff-only origin main` → Docker 이미지 재빌드 → 재배포로 진행합니다. Docker 구성과 환경변수가 바뀌지 않았다면 기존 YAML을 그대로 사용합니다. DB·업로드는 기존 볼륨에 유지되므로 매번 백업할 필요는 없습니다. DB 구조 변경에 대비하거나 데이터를 따로 보관하려면 앱을 정지한 뒤 DB 볼륨(WAL·SHM 포함), 업로드 볼륨, `.env`를 같은 시점에 백업합니다. 자세한 순서는 [deploy.md의 업데이트 절차](deploy.md#7-다음-버전으로-업데이트)를 참고하세요.
|
||||
@@ -85,7 +89,7 @@ QR·4자리 코드는 기본 10분의 임시 공간 초대입니다. 초대 코
|
||||
|
||||
## 현재 전송·보관함 흐름
|
||||
|
||||
보관함은 받은 항목으로 먼저 열립니다. 계정 설정에서 이메일·비밀번호별 변경 버튼을 눌러 입력 화면으로 이동하며, 모바일에서는 하단 시트로 표시합니다. 기기 설정에서는 이름만 변경합니다. 사이트에 접속하면 자동 발견에 참여하며, 이전 버전에서 발견을 꺼둔 기기도 다시 접속하면 켜집니다. 파일의 실제 저장 위치·다운로드 실패·만료·관리자 접근은 [deploy.md의 파일 저장 안내](deploy.md#전송-파일이-저장되는-곳과-정리-시점)에 정리했습니다.
|
||||
보관함은 받은 항목으로 먼저 열립니다. 계정 설정에서 이메일은 표시만 하고 비밀번호 변경 버튼을 눌러 입력 화면으로 이동하며, 모바일에서는 하단 시트로 표시합니다. 기기 설정에서는 이름만 변경합니다. 사이트에 접속하면 자동 발견에 참여하며, 이전 버전에서 발견을 꺼둔 기기도 다시 접속하면 켜집니다. 파일의 실제 저장 위치·다운로드 실패·만료·관리자 접근은 [deploy.md의 파일 저장 안내](deploy.md#전송-파일이-저장되는-곳과-정리-시점)에 정리했습니다.
|
||||
|
||||
최신 동작 기준은 [TEMPORARY_VAULT.md](docs/TEMPORARY_VAULT.md)입니다. 중앙에서 기기를 고른 뒤 PC 모달·모바일 하단 시트로 링크·파일·임시 저장 자료를 보냅니다. 로그인 중 받은 자료는 보관함의 받은 항목에 날짜별로 남고, 직접 담은 자료는 임시 저장에서 한 줄 메모와 함께 관리합니다. 모든 자료는 최대 7일입니다. 저장 종류는 먼저 링크/파일 중 하나를 선택합니다. 이메일 원격 전송과 영구 즐겨찾기는 제거했습니다.
|
||||
|
||||
|
||||
@@ -401,6 +401,7 @@ export class Accounts {
|
||||
)
|
||||
.get();
|
||||
if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.');
|
||||
if (row!.item.fileId) this.service.storage.availability.require(row!.item.fileId);
|
||||
return row!;
|
||||
}
|
||||
read(userId: string, id: string, value: boolean) {
|
||||
@@ -425,6 +426,7 @@ export class Accounts {
|
||||
query: { cursor?: string; limit: number; search?: string; kind?: string; read?: boolean },
|
||||
) {
|
||||
this.verified(userId);
|
||||
this.service.storage.availability.reconcile({ id: userId, kind: 'user' });
|
||||
let after: { createdAt: number; id: string } | undefined;
|
||||
if (query.cursor) {
|
||||
try {
|
||||
@@ -520,6 +522,7 @@ export class Accounts {
|
||||
};
|
||||
}
|
||||
vaultFiles(userId: string) {
|
||||
this.service.storage.availability.reconcile({ id: userId, kind: 'user' });
|
||||
const rows = this.service.db
|
||||
.select({ file: s.files })
|
||||
.from(s.access)
|
||||
|
||||
@@ -6,7 +6,6 @@ class PrivateLogs extends LogController {
|
||||
}
|
||||
import cookie from '@fastify/cookie';
|
||||
import multipart from '@fastify/multipart';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import serveStatic from '@fastify/static';
|
||||
import { Server } from 'socket.io';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
@@ -602,7 +601,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
app.get('/api/access/:id/download', async (req, reply) => {
|
||||
const accessId = id(req),
|
||||
g = accessSubject(req, accessId);
|
||||
const { file, path } = service.storage.download(g, accessId);
|
||||
const { file, stream } = await service.storage.openDownload(g, accessId);
|
||||
const fallback =
|
||||
file.name.replace(/[^a-zA-Z0-9._ -]/g, '_').replace(/^[. ]+/, '') || 'download';
|
||||
const encoded = encodeURIComponent(file.name).replace(
|
||||
@@ -616,8 +615,13 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
)
|
||||
.header('Content-Length', file.size)
|
||||
.type('application/octet-stream');
|
||||
readAccess(g, accessId, true);
|
||||
return reply.send(createReadStream(path));
|
||||
try {
|
||||
readAccess(g, accessId, true);
|
||||
} catch (error) {
|
||||
stream.destroy();
|
||||
throw error;
|
||||
}
|
||||
return reply.send(stream);
|
||||
});
|
||||
app.get('/api/access/:id/preview', async (req, reply) =>
|
||||
reply
|
||||
|
||||
115
apps/server/src/fileAvailability.js
Normal file
115
apps/server/src/fileAvailability.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { statSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { and, eq, gt, inArray } from 'drizzle-orm';
|
||||
import * as s from './db/schema.js';
|
||||
import { fail } from './security.js';
|
||||
|
||||
export class FileAvailability {
|
||||
constructor(service, directory) {
|
||||
this.service = service;
|
||||
this.directory = directory;
|
||||
this.pending = new Set();
|
||||
}
|
||||
check(file) {
|
||||
try {
|
||||
if (statSync(resolve(this.directory, file.storageKey)).isFile()) return true;
|
||||
} catch (error) {
|
||||
if (!['ENOENT', 'ENOTDIR'].includes(error.code))
|
||||
fail(
|
||||
503,
|
||||
'STORAGE_UNAVAILABLE',
|
||||
'파일 저장소에 접근할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
);
|
||||
}
|
||||
this.missing(file.id);
|
||||
return false;
|
||||
}
|
||||
require(id) {
|
||||
const file = this.service.db.select().from(s.files).where(eq(s.files.id, id)).get();
|
||||
if (!file || file.status !== 'stored' || !this.check(file))
|
||||
fail(404, 'FILE_NOT_FOUND', '서버의 파일이 삭제되었거나 만료되었습니다. 다시 전송해 주세요.');
|
||||
return file;
|
||||
}
|
||||
reconcile(subject) {
|
||||
const rows = this.service.db
|
||||
.select({ file: s.files })
|
||||
.from(s.files)
|
||||
.innerJoin(s.items, eq(s.items.fileId, s.files.id))
|
||||
.innerJoin(s.access, eq(s.access.itemId, s.items.id))
|
||||
.where(
|
||||
and(
|
||||
eq(s.files.status, 'stored'),
|
||||
subject.kind === 'user'
|
||||
? eq(s.access.userId, subject.id)
|
||||
: eq(s.access.guestId, subject.id),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
for (const file of new Map(rows.map(({ file }) => [file.id, file])).values()) this.check(file);
|
||||
}
|
||||
invalidate(id) {
|
||||
const db = this.service.db;
|
||||
return db.transaction(() => {
|
||||
const refs = db
|
||||
.select({ access: s.access })
|
||||
.from(s.access)
|
||||
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
|
||||
.where(eq(s.items.fileId, id))
|
||||
.all();
|
||||
const transfers = db
|
||||
.select({ id: s.requests.id })
|
||||
.from(s.vaultTransfers)
|
||||
.innerJoin(s.access, eq(s.vaultTransfers.accessId, s.access.id))
|
||||
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
|
||||
.innerJoin(s.requests, eq(s.vaultTransfers.requestId, s.requests.id))
|
||||
.innerJoin(s.batches, eq(s.batches.requestId, s.requests.id))
|
||||
.where(
|
||||
and(
|
||||
eq(s.items.fileId, id),
|
||||
inArray(s.requests.status, ['pending', 'accepted']),
|
||||
eq(s.batches.status, 'waiting'),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
const now = Date.now();
|
||||
db.update(s.files)
|
||||
.set({ status: 'failed', checksum: null, updatedAt: now })
|
||||
.where(and(eq(s.files.id, id), eq(s.files.status, 'stored')))
|
||||
.run();
|
||||
db.update(s.items)
|
||||
.set({ expiresAt: now })
|
||||
.where(and(eq(s.items.fileId, id), gt(s.items.expiresAt, now)))
|
||||
.run();
|
||||
db.delete(s.reservations).where(eq(s.reservations.fileId, id)).run();
|
||||
for (const transfer of transfers) {
|
||||
db.update(s.requests)
|
||||
.set({ status: 'cancelled' })
|
||||
.where(eq(s.requests.id, transfer.id))
|
||||
.run();
|
||||
db.update(s.batches)
|
||||
.set({ status: 'failed' })
|
||||
.where(eq(s.batches.requestId, transfer.id))
|
||||
.run();
|
||||
}
|
||||
return { refs, transfers };
|
||||
});
|
||||
}
|
||||
missing(id) {
|
||||
const affected = this.invalidate(id);
|
||||
if (this.pending.has(id)) return;
|
||||
this.pending.add(id);
|
||||
// A failed idempotent operation can roll back its outer transaction. Reapply afterward.
|
||||
queueMicrotask(() => {
|
||||
this.pending.delete(id);
|
||||
const file = this.service.db.select().from(s.files).where(eq(s.files.id, id)).get();
|
||||
const retried = file?.status === 'stored' ? this.invalidate(id) : { refs: [], transfers: [] };
|
||||
const refs = [...affected.refs, ...retried.refs].map(({ access }) => access);
|
||||
for (const user of new Set(refs.map((access) => access.userId).filter(Boolean)))
|
||||
this.service.accounts.notify(user);
|
||||
for (const guest of new Set(refs.map((access) => access.guestId).filter(Boolean)))
|
||||
this.service.presence.emit(guest, 'access:updated', {});
|
||||
for (const transfer of [...affected.transfers, ...retried.transfers])
|
||||
this.service.notifyTransfer(transfer.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -843,6 +843,7 @@ export class Service {
|
||||
}));
|
||||
}
|
||||
listItems(g: Guest, cursor?: string, limit = 30) {
|
||||
this.storage.availability.reconcile(g);
|
||||
let after: { createdAt: number; id: string } | undefined;
|
||||
if (cursor) {
|
||||
try {
|
||||
@@ -933,6 +934,7 @@ export class Service {
|
||||
)
|
||||
.get();
|
||||
if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.');
|
||||
if (row!.item.fileId) this.storage.availability.require(row!.item.fileId);
|
||||
return row!;
|
||||
}
|
||||
read(g: Guest, id: string, value: boolean) {
|
||||
@@ -950,6 +952,7 @@ export class Service {
|
||||
)
|
||||
.get();
|
||||
if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.');
|
||||
if (row!.item.fileId) this.storage.availability.require(row!.item.fileId);
|
||||
this.db
|
||||
.update(s.access)
|
||||
.set({ read: value })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdirSync, statfsSync, existsSync } from 'node:fs';
|
||||
import { createWriteStream, createReadStream } from 'node:fs';
|
||||
import { mkdirSync, statfsSync } from 'node:fs';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { rename, unlink, readdir, stat, open } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { Transform, Writable } from 'node:stream';
|
||||
@@ -11,6 +11,7 @@ import type { FastifyRequest } from 'fastify';
|
||||
import type { Guest, Service } from './service.js';
|
||||
import * as s from './db/schema.js';
|
||||
import { ApiError, fail } from './security.js';
|
||||
import { FileAvailability } from './fileAvailability.js';
|
||||
export type StorageSubject = { id: string; kind?: 'user'; sessionId?: string };
|
||||
export class Storage {
|
||||
private active = new Map<
|
||||
@@ -21,11 +22,13 @@ export class Storage {
|
||||
private cleanupPromise: Promise<void> | undefined;
|
||||
readonly temp: string;
|
||||
readonly originals: string;
|
||||
readonly availability: FileAvailability;
|
||||
constructor(readonly service: Service) {
|
||||
this.temp = resolve(service.cfg.uploadDir, 'temporary');
|
||||
this.originals = resolve(service.cfg.uploadDir, 'originals');
|
||||
mkdirSync(this.temp, { recursive: true, mode: 0o700 });
|
||||
mkdirSync(this.originals, { recursive: true, mode: 0o700 });
|
||||
this.availability = new FileAvailability(service, this.originals);
|
||||
}
|
||||
private request(g: StorageSubject, id: string) {
|
||||
return g.kind === 'user'
|
||||
@@ -72,6 +75,7 @@ export class Storage {
|
||||
.run();
|
||||
}
|
||||
private used(guestId?: string) {
|
||||
if (guestId) this.availability.reconcile({ id: guestId });
|
||||
return this.service.db
|
||||
.select({ bytes: sql<number>`coalesce(sum(${s.files.size}),0)` })
|
||||
.from(s.files)
|
||||
@@ -429,14 +433,34 @@ export class Storage {
|
||||
const row = this.access(g, accessId);
|
||||
if (row.item.kind !== 'file' || !row.item.fileId)
|
||||
fail(404, 'FILE_NOT_FOUND', '파일을 찾을 수 없습니다.');
|
||||
const file = this.service.db
|
||||
.select()
|
||||
.from(s.files)
|
||||
.where(and(eq(s.files.id, row.item.fileId!), eq(s.files.status, 'stored')))
|
||||
.get();
|
||||
if (!file || !existsSync(resolve(this.originals, file.storageKey)))
|
||||
fail(404, 'FILE_NOT_FOUND', '파일을 찾을 수 없습니다.');
|
||||
return { file: file!, path: resolve(this.originals, file!.storageKey) };
|
||||
const file = this.availability.require(row.item.fileId!);
|
||||
return { file, path: resolve(this.originals, file.storageKey) };
|
||||
}
|
||||
async openDownload(g: StorageSubject, accessId: string) {
|
||||
const { file, path } = this.download(g, accessId);
|
||||
try {
|
||||
const handle = await open(path, 'r');
|
||||
return { file, stream: handle.createReadStream() };
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
['ENOENT', 'ENOTDIR'].includes(String(error.code))
|
||||
) {
|
||||
this.availability.missing(file.id);
|
||||
throw new ApiError(
|
||||
404,
|
||||
'FILE_NOT_FOUND',
|
||||
'서버의 파일이 삭제되었습니다. 다시 전송해 주세요.',
|
||||
);
|
||||
}
|
||||
throw new ApiError(
|
||||
503,
|
||||
'STORAGE_UNAVAILABLE',
|
||||
'파일 저장소에 접근할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
);
|
||||
}
|
||||
}
|
||||
async preview(g: StorageSubject, id: string) {
|
||||
const { path } = this.download(g, id);
|
||||
@@ -582,6 +606,7 @@ export class Storage {
|
||||
.where(inArray(s.files.status, ['stored', 'cleaning']))
|
||||
.all();
|
||||
for (const file of all) {
|
||||
if (file.status === 'stored' && !this.availability.check(file)) continue;
|
||||
const refs = db
|
||||
.select({ id: s.items.id })
|
||||
.from(s.items)
|
||||
|
||||
271
apps/server/test/missing-files.test.js
Normal file
271
apps/server/test/missing-files.test.js
Normal file
@@ -0,0 +1,271 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { unlink, chmod } from 'node:fs/promises';
|
||||
import { unlinkSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { fixture } from './helpers.js';
|
||||
import * as s from '../src/db/schema.js';
|
||||
|
||||
async function signIn(f, client) {
|
||||
const id = randomUUID(),
|
||||
email = `${id}@hop.test`;
|
||||
f.service.db
|
||||
.insert(s.users)
|
||||
.values({
|
||||
id,
|
||||
email,
|
||||
displayName: email,
|
||||
passwordHash: await f.service.auth.password('1234'),
|
||||
status: 'active',
|
||||
verifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
.run();
|
||||
const response = await f.call(client, '/auth/login', 'POST', { email, password: '1234' });
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
client.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0];
|
||||
client.socket.disconnect();
|
||||
await f.connect(client);
|
||||
}
|
||||
async function save(f, client, bytes = Buffer.from('test')) {
|
||||
const request = await f.call(
|
||||
client,
|
||||
'/vault',
|
||||
'POST',
|
||||
{
|
||||
kind: 'file',
|
||||
files: [{ name: 'saved.txt', size: bytes.length, type: 'text/plain' }],
|
||||
},
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(request.statusCode, 200, request.body);
|
||||
const boundary = 'missing-file-test';
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="saved.txt"\r\nContent-Type: text/plain\r\n\r\n`,
|
||||
),
|
||||
bytes,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
]);
|
||||
const response = await f.call(
|
||||
client,
|
||||
`/uploads?requestId=${request.json().requestId}&fileIndex=0`,
|
||||
'POST',
|
||||
body,
|
||||
randomUUID(),
|
||||
{ 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||
);
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
const row = f.service.storage.fileRows(request.json().requestId)[0];
|
||||
const item = (await f.call(client, '/vault'))
|
||||
.json()
|
||||
.items.find((item) => item.file?.id === row.id);
|
||||
return { file: row, item, path: resolve(f.service.storage.originals, row.storageKey) };
|
||||
}
|
||||
async function forward(f, sender, receiver, item, accept = true) {
|
||||
const response = await f.call(
|
||||
sender,
|
||||
`/vault/${item.accessId}/send`,
|
||||
'POST',
|
||||
{ targetDeviceId: receiver.deviceId },
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
const id = response.json().requestId;
|
||||
if (accept)
|
||||
assert.equal(
|
||||
(await f.call(receiver, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()))
|
||||
.statusCode,
|
||||
200,
|
||||
);
|
||||
return id;
|
||||
}
|
||||
function limits(f) {
|
||||
f.cfg.sessionRate = 60;
|
||||
f.cfg.pairRate = 60;
|
||||
f.cfg.ipRate = 100;
|
||||
}
|
||||
|
||||
for (const trigger of ['download', 'list', 'quota', 'cleanup']) {
|
||||
test(`수동 삭제(${trigger}): 공유 원본의 모든 기록·개인 용량 정리와 정상 파일/링크·페이지 유지`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
limits(f);
|
||||
const a = await f.create(),
|
||||
b = await f.create(),
|
||||
outsider = await f.create();
|
||||
await f.space(a, b);
|
||||
await signIn(f, a);
|
||||
await signIn(f, b);
|
||||
const healthy = await save(f, a, Buffer.from('healthy'));
|
||||
const link = await f.call(
|
||||
a,
|
||||
'/vault',
|
||||
'POST',
|
||||
{ kind: 'url', urls: ['https://example.com/healthy'] },
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(link.statusCode, 200, link.body);
|
||||
const missing = await save(f, a);
|
||||
await forward(f, a, b, missing.item);
|
||||
const received = (await f.call(b, '/inbox')).json().items[0];
|
||||
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 11);
|
||||
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 4);
|
||||
await unlink(missing.path);
|
||||
const foreign = await f.call(outsider, `/access/${missing.item.accessId}/download`);
|
||||
assert(foreign.statusCode === 401 || foreign.statusCode === 404);
|
||||
assert.equal(
|
||||
f.service.db.select().from(s.files).where(eq(s.files.id, missing.file.id)).get().status,
|
||||
'stored',
|
||||
'무권한 접근으로 정리/존재 확인을 유발하지 않음',
|
||||
);
|
||||
if (trigger === 'download') {
|
||||
const response = await f.call(b, `/access/${received.accessId}/download`);
|
||||
assert.equal(response.statusCode, 404, response.body);
|
||||
assert.match(response.json().message, /다시 전송/);
|
||||
} else if (trigger === 'list') {
|
||||
await f.call(a, '/vault');
|
||||
} else if (trigger === 'quota') {
|
||||
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
|
||||
} else {
|
||||
const update = new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(Error('missing notification')), 2000);
|
||||
b.socket.once('inbox:updated', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await f.service.storage.cleanup();
|
||||
await update;
|
||||
}
|
||||
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
|
||||
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
|
||||
assert.equal(
|
||||
(await f.call(a, '/vault/contents'))
|
||||
.json()
|
||||
.items.some((i) => i.file?.id === missing.file.id),
|
||||
false,
|
||||
);
|
||||
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 7);
|
||||
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
|
||||
const page = (await f.call(a, '/vault?limit=1')).json();
|
||||
assert.equal(page.items.length, 1);
|
||||
assert(page.nextCursor);
|
||||
const next = (
|
||||
await f.call(a, `/vault?limit=1&cursor=${encodeURIComponent(page.nextCursor)}`)
|
||||
).json();
|
||||
assert.equal(next.items.length, 1);
|
||||
assert.equal(next.nextCursor, null);
|
||||
const remaining = [...page.items, ...next.items];
|
||||
assert(remaining.some((i) => i.file?.id === healthy.file.id));
|
||||
assert(remaining.some((i) => i.url === 'https://example.com/healthy'));
|
||||
assert.deepEqual(
|
||||
(await f.call(a, `/access/${healthy.item.accessId}/download`)).rawPayload,
|
||||
Buffer.from('healthy'),
|
||||
);
|
||||
assert.equal((await f.call(a, `/access/${missing.item.accessId}/preview`)).statusCode, 404);
|
||||
f.service.cleanup();
|
||||
await f.service.storage.cleanup();
|
||||
assert.equal((await f.call(a, '/vault')).json().items.length, 2);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const action of ['send', 'accept']) {
|
||||
test(`수동 삭제 뒤 ${action} 실패: 외부 멱등 트랜잭션 롤백 후에도 삭제 감지와 대기 전송 취소 유지`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
limits(f);
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
await signIn(f, a);
|
||||
const missing = await save(f, a);
|
||||
const pending = action === 'accept' ? await forward(f, a, b, missing.item, false) : null;
|
||||
await unlink(missing.path);
|
||||
const result =
|
||||
action === 'accept'
|
||||
? await f.call(b, `/transfer-requests/${pending}/accept`, 'POST', {}, randomUUID())
|
||||
: await f.call(
|
||||
a,
|
||||
`/vault/${missing.item.accessId}/send`,
|
||||
'POST',
|
||||
{ targetDeviceId: b.deviceId },
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(result.statusCode, 404, result.body);
|
||||
assert.equal(
|
||||
f.service.db.select().from(s.files).where(eq(s.files.id, missing.file.id)).get().status,
|
||||
'failed',
|
||||
);
|
||||
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 0);
|
||||
assert.equal((await f.call(a, '/vault')).json().items.length, 0);
|
||||
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
|
||||
if (pending) {
|
||||
assert.equal(
|
||||
f.service.db.select().from(s.requests).where(eq(s.requests.id, pending)).get().status,
|
||||
'cancelled',
|
||||
);
|
||||
assert.equal(
|
||||
f.service.db.select().from(s.batches).where(eq(s.batches.requestId, pending)).get()
|
||||
.status,
|
||||
'failed',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('권한 오류는 삭제로 오인하지 않고 503 안내 후 권한 복구 시 원본·DB·용량 유지', async (t) => {
|
||||
if (process.getuid?.() === 0) return t.skip('root bypasses filesystem permissions');
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create();
|
||||
await signIn(f, a);
|
||||
const saved = await save(f, a);
|
||||
await chmod(f.service.storage.originals, 0);
|
||||
const response = await f.call(a, `/access/${saved.item.accessId}/download`);
|
||||
assert.equal(response.statusCode, 503, response.body);
|
||||
assert.equal(response.json().code, 'STORAGE_UNAVAILABLE');
|
||||
assert.equal(
|
||||
f.service.db.select().from(s.files).where(eq(s.files.id, saved.file.id)).get().status,
|
||||
'stored',
|
||||
);
|
||||
await chmod(f.service.storage.originals, 0o700);
|
||||
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 4);
|
||||
assert.deepEqual(
|
||||
(await f.call(a, `/access/${saved.item.accessId}/download`)).rawPayload,
|
||||
Buffer.from('test'),
|
||||
);
|
||||
} finally {
|
||||
await chmod(f.service.storage.originals, 0o700);
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('메타데이터 확인 직후 원본이 사라져도 다운로드는 500 대신 명확한 404 반환', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create();
|
||||
await signIn(f, a);
|
||||
const saved = await save(f, a);
|
||||
const download = f.service.storage.download.bind(f.service.storage);
|
||||
f.service.storage.download = (...args) => {
|
||||
const result = download(...args);
|
||||
unlinkSync(saved.path);
|
||||
return result;
|
||||
};
|
||||
const response = await f.call(a, `/access/${saved.item.accessId}/download`);
|
||||
assert.equal(response.statusCode, 404, response.body);
|
||||
assert.equal(response.json().code, 'FILE_NOT_FOUND');
|
||||
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
@@ -65,8 +65,7 @@ const storage = ref({ usedBytes: 0, limitBytes: 0 });
|
||||
const securityAction = ref('none'),
|
||||
currentPassword = ref(''),
|
||||
newPassword = ref(''),
|
||||
newConfirmation = ref(''),
|
||||
newEmail = ref('');
|
||||
newConfirmation = ref('');
|
||||
const preferencesDialog = ref(null);
|
||||
let preferencesTrigger;
|
||||
watch(preferencesOpen, async (open) => {
|
||||
@@ -106,7 +105,6 @@ function selectSecurityAction(action = 'none') {
|
||||
currentPassword.value = '';
|
||||
newPassword.value = '';
|
||||
newConfirmation.value = '';
|
||||
newEmail.value = '';
|
||||
securityAction.value = action;
|
||||
}
|
||||
function openPreferences() {
|
||||
@@ -181,7 +179,6 @@ function clearPrivate() {
|
||||
currentPassword.value = '';
|
||||
newPassword.value = '';
|
||||
newConfirmation.value = '';
|
||||
newEmail.value = '';
|
||||
securityAction.value = 'none';
|
||||
}
|
||||
const refreshLists = createRefreshQueue(async () => {
|
||||
@@ -377,6 +374,12 @@ async function copy(item) {
|
||||
});
|
||||
}
|
||||
async function download(item) {
|
||||
try {
|
||||
await api(`/access/${item.accessId}`);
|
||||
} catch (error) {
|
||||
await refreshLists().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/access/${item.accessId}/download`;
|
||||
a.download = item.file.name;
|
||||
@@ -429,13 +432,6 @@ async function securitySubmit() {
|
||||
await refreshAccount();
|
||||
reconnect();
|
||||
message('비밀번호를 변경했습니다. 다시 로그인해 주세요.');
|
||||
} else {
|
||||
await api('/auth/change-email', 'POST', {
|
||||
email: newEmail.value,
|
||||
password: currentPassword.value,
|
||||
});
|
||||
selectSecurityAction();
|
||||
message('새 주소의 인증 메일을 확인해 주세요. 인증 후 변경됩니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -602,19 +598,19 @@ async function resendVerification() {
|
||||
</div>
|
||||
<div class="account-tabs" role="tablist" aria-label="보관함 목록">
|
||||
<button
|
||||
role="tab"
|
||||
:aria-selected="view === 'vault'"
|
||||
:class="{ active: view === 'vault' }"
|
||||
@click="view = 'vault'"
|
||||
>
|
||||
임시 저장</button
|
||||
><button
|
||||
role="tab"
|
||||
:aria-selected="view === 'inbox'"
|
||||
:class="{ active: view === 'inbox' }"
|
||||
@click="view = 'inbox'"
|
||||
>
|
||||
받은 항목
|
||||
받은 항목</button
|
||||
><button
|
||||
role="tab"
|
||||
:aria-selected="view === 'vault'"
|
||||
:class="{ active: view === 'vault' }"
|
||||
@click="view = 'vault'"
|
||||
>
|
||||
임시 저장
|
||||
</button>
|
||||
</div>
|
||||
<form class="compact-search" @submit.prevent="act(refreshLists)">
|
||||
@@ -662,7 +658,7 @@ async function resendVerification() {
|
||||
><button type="button" class="text-button" @click="editing = null">취소</button>
|
||||
</form>
|
||||
<div class="item-footer">
|
||||
<span>{{ date(item.expiresAt) }} 삭제</span>
|
||||
<span>보관 만료 · {{ date(item.expiresAt) }}까지</span>
|
||||
<div>
|
||||
<button class="button small" @click="emit('send-item', item)">
|
||||
<Send :size="14" />기기로 보내기</button
|
||||
@@ -801,13 +797,7 @@ async function resendVerification() {
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<h2>
|
||||
{{
|
||||
securityAction === 'password'
|
||||
? '비밀번호 변경'
|
||||
: securityAction === 'email'
|
||||
? '이메일 변경'
|
||||
: '계정 설정'
|
||||
}}
|
||||
{{ securityAction === 'password' ? '비밀번호 변경' : '계정 설정' }}
|
||||
</h2>
|
||||
</div>
|
||||
<button class="icon-button" @click="closePreferences" aria-label="계정 설정 닫기">
|
||||
@@ -820,13 +810,6 @@ async function resendVerification() {
|
||||
<strong>이메일</strong>
|
||||
<p>{{ account.email }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="button small"
|
||||
@click="selectSecurityAction('email')"
|
||||
aria-label="이메일 변경"
|
||||
>
|
||||
변경
|
||||
</button>
|
||||
</div>
|
||||
<div class="preference-item">
|
||||
<div>
|
||||
@@ -843,32 +826,26 @@ async function resendVerification() {
|
||||
</div>
|
||||
</div>
|
||||
<form v-else class="preferences-form" @submit.prevent="securitySubmit">
|
||||
<p v-if="securityAction === 'email'" class="preferences-hint">
|
||||
새 이메일로 받은 인증을 완료하면 변경됩니다.
|
||||
</p>
|
||||
<label
|
||||
>현재 비밀번호<input
|
||||
type="password"
|
||||
v-model="currentPassword"
|
||||
required
|
||||
autocomplete="current-password" /></label
|
||||
><template v-if="securityAction === 'password'"
|
||||
><label
|
||||
>새 비밀번호<input
|
||||
type="password"
|
||||
v-model="newPassword"
|
||||
:minlength="passwordMinLength"
|
||||
required
|
||||
autocomplete="new-password" /></label
|
||||
><label
|
||||
>새 비밀번호 확인<input
|
||||
type="password"
|
||||
v-model="newConfirmation"
|
||||
:minlength="passwordMinLength"
|
||||
required
|
||||
autocomplete="new-password" /></label></template
|
||||
><label v-else
|
||||
>새 이메일<input type="email" v-model="newEmail" required autocomplete="email" /></label
|
||||
><label
|
||||
>새 비밀번호<input
|
||||
type="password"
|
||||
v-model="newPassword"
|
||||
:minlength="passwordMinLength"
|
||||
required
|
||||
autocomplete="new-password" /></label
|
||||
><label
|
||||
>새 비밀번호 확인<input
|
||||
type="password"
|
||||
v-model="newConfirmation"
|
||||
:minlength="passwordMinLength"
|
||||
required
|
||||
autocomplete="new-password" /></label
|
||||
><button class="button primary" :disabled="busy">{{ busy ? '변경 중…' : '변경' }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -47,6 +47,7 @@ import { createNearbySearch } from './nearbySearch';
|
||||
import { useVisibleViewport } from './viewport';
|
||||
useVisibleViewport();
|
||||
import { createDeliveryReceiver } from './deliveryReceiver';
|
||||
import { createRefreshQueue } from './refreshQueue';
|
||||
import { initialTheme, applyTheme as setTheme } from './theme';
|
||||
import {
|
||||
device,
|
||||
@@ -423,16 +424,31 @@ const vaultItems = ref([]),
|
||||
vaultCursor = ref(null),
|
||||
forwardedItem = ref(null),
|
||||
itemToSend = ref(null);
|
||||
async function loadVaultChoices() {
|
||||
const refreshVaultChoices = createRefreshQueue(async () => {
|
||||
if (!account.value) return;
|
||||
const owner = account.value.id;
|
||||
await act(async () => {
|
||||
const result = await api('/vault/contents');
|
||||
const wanted = Math.max(30, vaultItems.value.length);
|
||||
const refreshed = [];
|
||||
let cursor = null;
|
||||
do {
|
||||
const query = new URLSearchParams({ limit: String(Math.min(100, wanted - refreshed.length)) });
|
||||
if (cursor) query.set('cursor', cursor);
|
||||
const result = await api(`/vault/contents?${query}`);
|
||||
if (account.value?.id !== owner) return;
|
||||
vaultItems.value = result.items;
|
||||
vaultCursor.value = result.nextCursor;
|
||||
});
|
||||
refreshed.push(...result.items);
|
||||
cursor = result.nextCursor;
|
||||
} while (cursor && refreshed.length < wanted);
|
||||
vaultItems.value = refreshed;
|
||||
vaultCursor.value = cursor;
|
||||
});
|
||||
async function loadVaultChoices() {
|
||||
await act(refreshVaultChoices);
|
||||
}
|
||||
const onVaultUpdated = () => {
|
||||
if (sendOpen.value && account.value && vaultItems.value.length)
|
||||
void refreshVaultChoices().catch(showError);
|
||||
};
|
||||
window.addEventListener('hop-account-updated', onVaultUpdated);
|
||||
async function moreVault() {
|
||||
const owner = account.value?.id;
|
||||
await act(async () => {
|
||||
@@ -515,6 +531,7 @@ const onVisibility = () => {
|
||||
window.addEventListener('focus', onFocus);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('hop-account-updated', onVaultUpdated);
|
||||
window.removeEventListener('hop-receipt-skipped', onReceiptSkipped);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
@@ -831,7 +848,17 @@ async function spaceAction(space, action, memberId) {
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="receivedLinks.length" class="received-link-notice" role="status">
|
||||
<div
|
||||
v-if="
|
||||
receivedLinks.length &&
|
||||
!sendOpen &&
|
||||
!modal &&
|
||||
!incoming.length &&
|
||||
!receivedTexts.length
|
||||
"
|
||||
class="received-link-notice"
|
||||
role="status"
|
||||
>
|
||||
<Link :size="18" /><span>링크를 열었어요.</span
|
||||
><button class="text-button" @click="copyReceived(receivedLinks[0])">주소 복사</button
|
||||
><button
|
||||
@@ -1116,7 +1143,7 @@ async function spaceAction(space, action, memberId) {
|
||||
<div v-if="error" class="error-toast" role="alert">
|
||||
<span>{{ error }}</span
|
||||
><button class="icon-button" @click="error = ''" aria-label="오류 닫기">
|
||||
<X :size="17" />
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="modal" class="modal-backdrop" @click.self="modal = null" @keydown.esc="modal = null">
|
||||
|
||||
@@ -146,8 +146,17 @@ function connect() {
|
||||
])
|
||||
socket.on(event, () => void refresh().catch(showError));
|
||||
}
|
||||
let errorTimer;
|
||||
export function showError(err) {
|
||||
error.value = err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.';
|
||||
clearTimeout(errorTimer);
|
||||
const raw = err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.';
|
||||
const message = /^(Failed to fetch|NetworkError|Load failed)$/i.test(raw)
|
||||
? '서버에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.'
|
||||
: raw;
|
||||
error.value = message;
|
||||
errorTimer = setTimeout(() => {
|
||||
if (error.value === message) error.value = '';
|
||||
}, 5000);
|
||||
}
|
||||
export const refresh = createRefreshQueue(async () => {
|
||||
const snapshot = await api('/device/snapshot');
|
||||
@@ -523,6 +532,12 @@ export async function undoDelete(id) {
|
||||
await refresh();
|
||||
}
|
||||
export async function download(item) {
|
||||
try {
|
||||
await api(`/access/${item.accessId}`);
|
||||
} catch (err) {
|
||||
await refresh().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/access/${item.accessId}/download`;
|
||||
a.download = item.file?.name ?? '';
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
|
||||
const blockedLinks = ref([]);
|
||||
const receivedTexts = ref([]);
|
||||
const receivedLinks = ref([]);
|
||||
let linkNoticeTimer;
|
||||
function reserve(request) {
|
||||
if (pending.has(request.id)) return;
|
||||
const tabs = [];
|
||||
@@ -57,6 +58,10 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
|
||||
try {
|
||||
tab.location.replace(item.url);
|
||||
receivedLinks.value = [item];
|
||||
clearTimeout(linkNoticeTimer);
|
||||
linkNoticeTimer = setTimeout(() => {
|
||||
receivedLinks.value = [];
|
||||
}, 5000);
|
||||
void markRead(item).catch(onError);
|
||||
} catch {
|
||||
tab.close();
|
||||
@@ -73,6 +78,7 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
|
||||
void markRead(item).catch(onError);
|
||||
}
|
||||
function dispose() {
|
||||
clearTimeout(linkNoticeTimer);
|
||||
for (const id of pending.keys()) abort(id);
|
||||
blockedLinks.value = [];
|
||||
receivedTexts.value = [];
|
||||
|
||||
@@ -1186,10 +1186,21 @@ h1 span {
|
||||
bottom: 90px;
|
||||
background: #8b4036;
|
||||
color: #fff;
|
||||
padding: 7px 12px;
|
||||
padding: 4px 16px;
|
||||
gap: 0;
|
||||
}
|
||||
.error-toast button {
|
||||
.error-toast > span {
|
||||
min-width: 0;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error-toast .icon-button {
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
margin-right: -13px;
|
||||
}
|
||||
.inline-info {
|
||||
font-size: 11px;
|
||||
|
||||
15
compose.yaml
15
compose.yaml
@@ -7,6 +7,7 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: hop
|
||||
user: '${HOP_RUN_UID:-1000}:${HOP_RUN_GID:-1000}'
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
@@ -17,8 +18,16 @@ services:
|
||||
ports:
|
||||
- '${HOP_BIND_IP:?접속을 허용할 호스트 IP를 지정하세요}:${HOP_HOST_PORT:?충돌 없는 호스트 포트를 지정하세요}:${HOP_CONTAINER_PORT:?내부 포트를 지정하세요}'
|
||||
volumes:
|
||||
- hop_database:/data/database
|
||||
- hop_uploads:/data/uploads
|
||||
- type: volume
|
||||
source: hop_database
|
||||
target: /data/database
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: bind
|
||||
source: ./uploads
|
||||
target: /data/uploads
|
||||
bind:
|
||||
create_host_path: false
|
||||
networks:
|
||||
- hop_network
|
||||
stop_grace_period: 30s
|
||||
@@ -30,8 +39,6 @@ services:
|
||||
volumes:
|
||||
hop_database:
|
||||
name: hop_hop_database
|
||||
hop_uploads:
|
||||
name: hop_hop_uploads
|
||||
networks:
|
||||
hop_network:
|
||||
driver: bridge
|
||||
|
||||
94
deploy.md
94
deploy.md
@@ -6,7 +6,7 @@
|
||||
|
||||
아래 코드 블록을 하나씩 복사하고 Enter를 누르세요. 오류가 나오면 다음 단계로 넘어가지 마세요.
|
||||
|
||||
이미 배포했다면 **7. 업데이트**부터 진행하세요. 이번 업데이트는 **자동 근처 검색·기종 구분·연결 방식 선택·4자리 초대 코드·모바일 입력/진행률 개선**를 추가하며 기존 DB·업로드 볼륨을 유지합니다. 새 도메인이나 외부 포트는 추가하지 않습니다.
|
||||
이미 배포했다면 이번에는 **7-1. 업로드 폴더 전환과 테스트 자료 초기화**부터 진행하세요. 파일은 hop 폴더 안의 `uploads`에 저장합니다. 사용자가 기존 테스트 자료 삭제를 요청했으므로 최초 전환 때만 DB·기존 업로드를 초기화합니다. `.env`, 도메인·포트·메일 설정은 유지합니다. 전환 후 일반 업데이트는 7단계만 진행합니다.
|
||||
|
||||
**‘허용되지 않은 접속 경로’가 나오면:** 주소창을 `https://hop.noname.company`로 바꾸세요. `http://`는 운영 `APP_URL`과 달라 차단됩니다. 5-1단계에서 HTTP를 HTTPS로 자동 이동시킵니다.
|
||||
|
||||
@@ -47,7 +47,7 @@ git branch --show-current
|
||||
## 2. NAS 설정 만들기
|
||||
|
||||
```sh
|
||||
sh scripts/configure-nas.sh
|
||||
sudo sh scripts/configure-nas.sh
|
||||
```
|
||||
|
||||
질문이 나오면 순서대로 입력합니다.
|
||||
@@ -56,6 +56,8 @@ sh scripts/configure-nas.sh
|
||||
2. **Resend API 키**: 기존 `hello@noname.company` 발신에 사용하는 키 붙여넣기 → Enter. 입력 문자는 숨겨집니다. 메일 기능 없이 시작하려면 Enter만 누릅니다.
|
||||
3. **회원가입 활성화**: 가입까지 테스트하려면 `y` → Enter. 비활성화하려면 Enter만 누릅니다. 키를 생략하면 이 질문 없이 가입이 비활성화됩니다.
|
||||
|
||||
`uploads` 폴더를 만들고 NAS 로그인 사용자의 UID/GID로 저장 권한을 준비합니다. Docker 앱도 같은 UID/GID로 실행하므로 새로 받은 파일을 NAS 파일 관리자에서 읽을 수 있습니다. 처음에는 권한 준비용 작은 Alpine 이미지가 다운로드될 수 있습니다. 기존 프로젝트가 실행 중이면 먼저 중지하세요.
|
||||
|
||||
`SESSION_SECRET`과 `DISCOVERY_SECRET`은 자동 생성됩니다. NAS 포트와 컨테이너 포트는 모두 **50189**를 사용합니다.
|
||||
|
||||
마지막에 표시된 `compose.nas.yaml` 경로를 복사해 둡니다. `.env`는 NAS에만 남고 Git에 올라가지 않습니다. 기존 `.env`가 있으면 덮어쓰지 않습니다.
|
||||
@@ -209,17 +211,15 @@ https://hop.noname.company
|
||||
|
||||
현재 흐름은 **수락 → 서버로 업로드 → NAS에 원본 저장 → 수신자가 다운로드 → 보관 기간 종료 후 정리**입니다. 다운로드 완료 직후 삭제하는 방식이 아닙니다. 임시 저장과 받은 항목도 같은 파일 저장소를 사용하며 DB의 접근 권한으로 사용자별 목록을 구분합니다.
|
||||
|
||||
| 데이터 | Docker 볼륨 | 컨테이너 내부 위치 |
|
||||
| --------------------------------- | ------------------ | --------------------------------------------------- |
|
||||
| 업로드 완료된 파일 원본 | `hop_hop_uploads` | `/data/uploads/originals/` |
|
||||
| 업로드 중인 파일·조각 | `hop_hop_uploads` | `/data/uploads/temporary/`, `/data/uploads/chunks/` |
|
||||
| 계정·파일명·소유자·만료·접근 권한 | `hop_hop_database` | `/data/database/hop.sqlite` |
|
||||
| 데이터 | NAS 저장 위치 | 컨테이너 내부 위치 |
|
||||
| --------------------------------- | ----------------------------------------------- | --------------------------------------------------- |
|
||||
| 업로드 완료된 파일 원본 | `hop/uploads/originals/` | `/data/uploads/originals/` |
|
||||
| 업로드 중인 파일·조각 | `hop/uploads/temporary/`, `hop/uploads/chunks/` | `/data/uploads/temporary/`, `/data/uploads/chunks/` |
|
||||
| 계정·파일명·소유자·만료·접근 권한 | `hop_hop_database` Docker 볼륨 | `/data/database/hop.sqlite` |
|
||||
|
||||
원본 파일은 무작위 식별자로 저장합니다. 원래 파일명과 사용자 연결 정보는 DB에 있습니다. NAS의 실제 저장 경로는 아래 명령으로 확인합니다.
|
||||
NAS 파일 관리자에서 **공유 폴더 → docker → projects → apps → hop → uploads → originals**로 들어갑니다. 전체 경로는 `/volume1/docker/projects/apps/hop/uploads/originals`입니다. 원본은 무작위 식별자로 저장하고 원래 파일명과 사용자 연결 정보는 DB에 둡니다. 업로드 폴더는 Git과 Docker 이미지에서 제외합니다. NAS 공유 폴더를 컨테이너에 연결하는 방식은 [Docker 공식 설명](https://docs.docker.com/engine/storage/bind-mounts/)과 [UGREEN 공식 안내](https://support.ugnas.com/detail/article/en-US/611)를 참고했습니다.
|
||||
|
||||
```sh
|
||||
sudo docker volume inspect hop_hop_uploads --format '{{.Mountpoint}}'
|
||||
```
|
||||
파일 관리자로 원본을 수동 삭제하면 다음 보관함·용량 조회, 다운로드·재전송 시 확인하거나 약 1분 간격의 정리 작업에서 감지합니다. 같은 원본의 모든 계정·기기 항목은 만료 처리해 목록과 전송 선택에서 제외하고 사용량을 회수합니다. 열린 화면에는 갱신 이벤트를 보내며 이미 열린 다운로드·전송 화면에서는 삭제 안내를 표시합니다. 권한 오류는 503 안내를 반환하고 DB 기록을 삭제하지 않습니다. 원본을 수동으로 넣거나 이름을 바꿔도 새 보관함 항목을 자동 생성하지 않습니다. `temporary`·`chunks`는 진행 중 업로드에 사용하는 폴더입니다.
|
||||
|
||||
- **다운로드 실패·수신자 이탈:** 이미 저장된 원본은 바로 삭제되지 않습니다. 받은 항목에 저장된 파일은 로그인 후 만료 전에 다시 다운로드할 수 있습니다. 개인 용량 초과로 받은 항목에 저장되지 않았거나 비로그인으로 받은 경우, 사이트를 떠난 뒤 이전 다운로드를 다시 찾는 UI는 제공하지 않습니다. 이때는 다시 전송하는 방법을 사용합니다. 다운로드 시작 표시는 기기에 저장이 끝났다는 확인을 의미하지 않습니다.
|
||||
- **업로드 도중 이탈:** 완료되지 않은 업로드는 받은 항목에 생성되지 않습니다. 취소된 조각은 제거하며, 남은 미완료·고아 업로드는 기본 24시간 후 정리합니다. 브라우저를 떠난 뒤 자동 전송·자동 다운로드 재개는 보장하지 않습니다.
|
||||
@@ -252,9 +252,67 @@ git pull --ff-only origin main
|
||||
|
||||
앱의 재배포 버튼이 이미지 빌드까지 함께 실행한다면 그 버튼으로 진행해도 됩니다. 기존 이미지를 그대로 쓰는 단순 재시작은 새 코드를 반영하지 않습니다.
|
||||
|
||||
**이번 업데이트도 이 순서로 진행하면 됩니다.** `sh scripts/configure-nas.sh`를 다시 실행하거나 YAML을 다시 불러올 필요가 없습니다. NPM·Cloudflare Tunnel 설정도 그대로 유지합니다. 위치 검색은 양쪽 브라우저에서 위치 권한을 허용하면 자동으로 동작합니다.
|
||||
**저장소 전환을 완료한 뒤의 코드·UI 업데이트는 이 순서만 진행합니다.** 설정 스크립트를 다시 실행하거나 YAML을 다시 불러올 필요가 없습니다. NPM·Cloudflare Tunnel 설정도 그대로 유지합니다. 위치 검색은 양쪽 브라우저에서 위치 권한을 허용하면 자동으로 동작합니다.
|
||||
|
||||
DB와 파일은 `hop_hop_database`, `hop_hop_uploads` 볼륨에 있고 `.env`와 생성된 `compose.nas.yaml`은 Git에서 제외되어 있으므로 일반 코드 업데이트로 덮어쓰지 않습니다. 같은 볼륨을 사용하는 재배포에서 데이터는 유지됩니다.
|
||||
DB는 `hop_hop_database` 볼륨, 파일은 hop 폴더의 `uploads`에 있습니다. `uploads`, `.env`, 생성된 `compose.nas.yaml`은 Git에서 제외하므로 일반 코드 업데이트로 덮어쓰지 않습니다. 같은 저장 경로를 사용하는 재배포에서 데이터는 유지됩니다.
|
||||
|
||||
### 7-1. 업로드 폴더 전환과 테스트 자료 초기화
|
||||
|
||||
**이번에 한 번만 실행합니다. 기존 테스트 계정·기기 이름·방·전송 기록·보관 파일은 모두 삭제합니다. `.env`와 소스는 유지합니다.** 다음 업데이트에서 반복하지 마세요.
|
||||
|
||||
1. Docker 앱 → 프로젝트 → hop → **중지**.
|
||||
2. SSH에서 hop 폴더로 이동:
|
||||
|
||||
```sh
|
||||
cd /volume1/docker/projects/apps/hop
|
||||
```
|
||||
|
||||
3. 최신 코드 받기:
|
||||
|
||||
```sh
|
||||
git pull --ff-only origin main
|
||||
```
|
||||
|
||||
4. 중지된 hop 컨테이너 제거. 프로젝트 소스 폴더는 남습니다:
|
||||
|
||||
```sh
|
||||
sudo docker rm hop
|
||||
```
|
||||
|
||||
5. 기존 테스트 DB와 업로드 볼륨 삭제:
|
||||
|
||||
```sh
|
||||
sudo docker volume rm hop_hop_database hop_hop_uploads
|
||||
```
|
||||
|
||||
6. 새 업로드 폴더·권한·YAML 준비. 기존 `.env`의 비밀값은 재발급하지 않습니다:
|
||||
|
||||
```sh
|
||||
sudo sh scripts/configure-nas.sh
|
||||
```
|
||||
|
||||
7. Docker 앱 → 기존 hop 프로젝트의 **편집 / Compose 설정**에서 새 `compose.nas.yaml`을 다시 불러옵니다. 파일 가져오기가 없으면 아래 내용을 Compose 편집기에 붙여넣습니다:
|
||||
|
||||
```sh
|
||||
cat compose.nas.yaml
|
||||
```
|
||||
|
||||
8. **이미지 재빌드 → 재배포 / 시작**. `healthy` 확인.
|
||||
9. SSH에서 실제 파일 연결 경로 확인:
|
||||
|
||||
```sh
|
||||
sudo docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data/uploads"}}{{.Type}} → {{.Source}}{{end}}{{end}}' hop
|
||||
```
|
||||
|
||||
다음처럼 나오면 완료입니다:
|
||||
|
||||
```text
|
||||
bind → /volume1/docker/projects/apps/hop/uploads
|
||||
```
|
||||
|
||||
10. 브라우저/PWA 새로고침 → 필요하면 다시 회원가입 → 작은 파일 전송 → NAS 파일 관리자에서 **hop → uploads → originals** 확인.
|
||||
|
||||
이후에는 Git pull → 이미지 재빌드 → 재배포만 진행합니다. `uploads` 폴더와 DB는 그대로 유지합니다.
|
||||
|
||||
### 추가 작업이 필요한 업데이트
|
||||
|
||||
@@ -287,11 +345,11 @@ chmod 700 backups
|
||||
```
|
||||
|
||||
```sh
|
||||
sudo docker volume inspect hop_hop_database hop_hop_uploads >/dev/null
|
||||
sudo docker volume inspect hop_hop_database >/dev/null
|
||||
```
|
||||
|
||||
```sh
|
||||
sudo docker run --rm -v hop_hop_database:/source/database:ro -v hop_hop_uploads:/source/uploads:ro -v "$PWD/backups:/backup" alpine:3.22 sh -c 'tar -czf "/backup/hop-$(date +%Y%m%d-%H%M%S).tar.gz" -C /source database uploads'
|
||||
sudo docker run --rm -v hop_hop_database:/source/database:ro -v "$PWD/uploads:/source/uploads:ro" -v "$PWD/backups:/backup" alpine:3.22 sh -c 'tar -czf "/backup/hop-$(date +%Y%m%d-%H%M%S).tar.gz" -C /source database uploads'
|
||||
```
|
||||
|
||||
```sh
|
||||
@@ -343,7 +401,7 @@ git pull --ff-only origin main
|
||||
```
|
||||
|
||||
```sh
|
||||
sh scripts/configure-nas.sh
|
||||
sudo sh scripts/configure-nas.sh
|
||||
```
|
||||
|
||||
Docker 앱에서 **새 compose.nas.yaml 다시 불러오기 → 저장 → 재빌드 → 시작 / 재배포** 순서로 진행합니다. 새 YAML은 `pull_policy: never`를 사용합니다. 기존 YAML을 그대로 쓰면 수정이 적용되지 않습니다.
|
||||
@@ -365,3 +423,7 @@ Docker 앱에서 **새 compose.nas.yaml 다시 불러오기 → 저장 → 재
|
||||
일반 텍스트·단일 링크 자동 판별과 텍스트 수신 미리보기·복사를 추가했습니다. 이번 변경은 기존 텍스트 컬럼을 재사용하므로 DB 마이그레이션·YAML 변경 없이 **Git pull → 이미지 재빌드 → 재배포**로 적용합니다. 기존 `.env`에 새 설정이 없어도 텍스트 한도는 256KiB입니다. 한도를 별도로 조정할 때만 `MAX_TEXT_PAYLOAD_SIZE_KB`를 변경하고 환경설정을 재배포하세요.
|
||||
|
||||
브라우저마다 클립보드 권한 방식이 다릅니다. 첫 읽기가 제한되어도 직접 입력하거나 ‘다시 가져오기’를 사용할 수 있습니다. ‘다시 가져오기’에서 브라우저의 붙여넣기 확인이 뜨는 것은 권한 확인 절차입니다. 기기 카드 자체의 텍스트 선택은 차단하고 입력창·받은 텍스트의 직접 선택과 붙여넣기는 유지합니다.
|
||||
|
||||
## 화면 안내 정리
|
||||
|
||||
보관함 탭은 **받은 항목 → 임시 저장** 순서입니다. 항목의 날짜는 **보관 만료 · 날짜/시간까지**로 표시합니다. 계정 설정에서 이메일은 표시만 하고 변경 버튼은 숨깁니다. 비밀번호 변경은 유지합니다. 오류 토스트는 텍스트와 닫기 아이콘의 양쪽 여백을 맞추며 기본 5초 뒤 닫힙니다. 네트워크 연결 실패는 한국어로 안내하고 닫기 버튼으로 즉시 닫을 수도 있습니다.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
비로그인 방문자에게 전송 내역을 보여주지 않는다. 로그인한 계정이 수락하고 그 세션이 전달 완료까지 유효한 경우 받은 항목에 기록한다. 로그인 전 받은 자료는 가져오지 않는다. 받은 항목은 날짜별로 묶는다. 자동 열기/다운로드 후 읽음은 기기 접근권과 계정 기록에 함께 반영한다.
|
||||
|
||||
오른쪽 보관함 버튼을 누르면 받은 항목을 먼저 표시하고 `임시 저장 / 받은 항목`을 전환할 수 있다. 임시 저장은 자신이 클립보드 콘텐츠·파일을 담는 공간이다. 먼저 `클립보드 저장 / 파일 저장` 중 하나를 선택하고 해당 입력만 표시한다. 선택적인 한 줄 제목/메모는 최대 200자이며 원래 만료를 연장하지 않고 수정할 수 있다. 제목 아래 보관 설명은 생략하고 하단 용량 안내와 함께 `항목별 최대 7일 보관`을 표시한다. 계정 설정은 이메일·비밀번호별 변경 버튼을 누른 뒤 해당 폼을 열며 모바일에서는 하단 시트로 표시한다. 이메일 주소로 다른 사람에게 보내는 새 요청은 제거했다. Resend는 가입·인증·비밀번호 복구 등 계정 메일에 계속 사용한다.
|
||||
오른쪽 보관함 버튼을 누르면 받은 항목을 먼저 표시하고 `받은 항목 / 임시 저장`을 전환할 수 있다. 임시 저장은 자신이 클립보드 콘텐츠·파일을 담는 공간이다. 먼저 `클립보드 저장 / 파일 저장` 중 하나를 선택하고 해당 입력만 표시한다. 선택적인 한 줄 제목/메모는 최대 200자이며 원래 만료를 연장하지 않고 수정할 수 있다. 제목 아래 보관 설명은 생략하고 하단 용량 안내와 함께 `항목별 최대 7일 보관`을 표시한다. 계정 설정에서 이메일 변경 버튼은 숨기고 비밀번호 변경 버튼으로 해당 폼을 열며 모바일에서는 하단 시트로 표시한다. 이메일 주소로 다른 사람에게 보내는 새 요청은 제거했다. Resend는 가입·인증·비밀번호 복구 등 계정 메일에 계속 사용한다.
|
||||
|
||||
기기 전송은 새 링크, 새 파일, 로그인한 사용자의 보관함 자료 중에서 보낸다. 보관함은 임시 저장과 받은 항목을 포함한 세 번째 콘텐츠 선택 방법이며 비로그인 화면에서는 숨긴다. 항목 선택은 미리보기만 바꾸고 한 개의 전송 버튼으로 확정한다. 저장한 자료도 대상 기기가 수락한 뒤에 전달한다. 저장된 파일은 서버의 원본을 재사용해 브라우저 재다운로드·재업로드와 중복 물리 저장을 피한다. 원본의 개인 메모는 상대에게 복제하지 않는다. 다른 계정의 자료, 만료·삭제한 원본, 폐기된 발신 계정 세션으로는 보낼 수 없다.
|
||||
|
||||
|
||||
@@ -137,3 +137,12 @@
|
||||
- 내 기기 설정의 발견 허용 스위치를 제거했다. 이전 버전에서 발견을 꺼둔 세션도 재접속 시 켜지며 기기 ID·저장한 이름을 유지하는지 검증했다. 기존 서버의 접근 제어·방 격리와 발견 차단 API는 유지한다.
|
||||
- 전체 빌드·타입 검사와 API 테스트 80개 통과. 계정 보관함·반응형 레이아웃·익명 파일/링크 전송·클립보드 텍스트·위치 자동 발견 브라우저 검증 5종을 통과했다. 실제 계정 메일은 테스트 메일러로 검증하며 외부 Resend 발송은 수행하지 않았다.
|
||||
- 파일 원본·조각·DB의 영구 볼륨 위치, 다운로드 실패·사이트 이탈, 마지막 유효 참조가 끝난 뒤 원본 정리와 관리자 접근 범위를 구현 코드로 확인해 deploy.md에 기록했다. 파일 보관 정책·DB 구조·YAML·환경변수는 변경하지 않았다. NAS 원격 배포는 수행하지 않았다.
|
||||
|
||||
## 2026-10-13 공유 폴더 저장·수동 삭제 대비와 안내 정리
|
||||
|
||||
- 파일을 프로젝트의 `uploads` 바인드 마운트에 저장하며 Git·Docker 빌드에서 제외한다. NAS 로그인 사용자의 UID/GID로 앱을 실행하고 소유자 전용 저장 권한을 준비한다. DB 볼륨에는 Compose 라벨과 nocopy를 적용해 처음 만든 빈 볼륨의 소유권을 유지한다. 실행 중인 프로젝트의 권한 변경과 이전 업로드 볼륨을 연결한 상태의 전환은 설정 스크립트에서 거절한다.
|
||||
- 사용자 요청대로 기존 NAS 테스트 자료를 복사하지 않는 최초 초기화 절차를 deploy.md 7-1에 기록했다. `.env`는 유지하며 설정 스크립트 자체는 데이터를 삭제하지 않는다. 초기화 명령은 NAS에서 사용자가 실행해야 하고 이후 코드 업데이트에서는 반복하지 않는다.
|
||||
- 수동 삭제 검증 8개를 추가해 API 총 88개를 통과했다. 조회·개인 용량·다운로드·1분 정리 작업으로 사라진 원본을 감지하며 공유된 모든 항목의 만료·용량 회수·정상 파일/링크 및 페이지 유지·소켓 알림·수락 대기 취소를 확인했다. 멱등 트랜잭션 실패/롤백 후에도 정리를 유지하고 다운로드 직전 삭제는 404, 권한 오류는 기록을 유지한 503으로 처리한다.
|
||||
- 계정 브라우저 검증에서 NAS 저장소에 해당하는 실제 서버 원본을 삭제한 뒤 다운로드 전 안내·양쪽 보관함과 사용량 정리·다른 기기의 열린 전송 선택 해제·전송 버튼 비활성화를 확인했다. 기존 저장·메모·삭제/복원·수락 후 자동 열기/다운로드·로그아웃 권한 격리는 유지한다.
|
||||
- 오류 토스트의 좌우 여백·텍스트/아이콘 중앙 정렬·44px 닫기 버튼·5초 자동 닫힘, 모바일 네트워크 실패의 한국어 안내와 수동 닫힘을 실제 Chromium에서 확인했다. 링크 수신 안내도 5초 뒤 닫히고 새 전송 모달을 가리지 않는다. 이메일 변경 버튼은 숨기고 받은 항목/임시 저장 탭 순서와 보관 만료 날짜 문구를 정리했다.
|
||||
- 전체 빌드·타입 검사, 계정·반응형 레이아웃·익명 파일/링크·클립보드 텍스트 브라우저 검증을 통과했다. 운영 Docker 이미지 빌드, 기본 UID와 다른 NAS UID/GID를 사용하는 실제 격리 Compose 시작/health·영구 DB·호스트 바인드 마운트·공개 호스트 포트 없음, 재시작 뒤 파일/텍스트/세션 보존과 8MiB 미완료 조각 이어 보내기·SQLite integrity·호스트 파일 삭제를 확인했다. 실제 NAS·파일 관리자·실물 Safari의 확인과 재배포는 수행하지 않았다.
|
||||
|
||||
@@ -61,7 +61,7 @@ if [ ! -f .env ]; then
|
||||
hop_session_secret=$(openssl rand -hex 32)
|
||||
hop_discovery_secret=$(openssl rand -hex 32)
|
||||
hop_env_tmp=$(mktemp .env.nas.XXXXXX)
|
||||
awk '!/^(HOP_BIND_IP|HOP_HOST_PORT|HOP_CONTAINER_PORT|APP_URL|SESSION_SECRET|DISCOVERY_SECRET|RESEND_API_KEY|REGISTRATION_MODE)=/' .env.example > "$hop_env_tmp"
|
||||
awk '!/^(HOP_BIND_IP|HOP_HOST_PORT|HOP_CONTAINER_PORT|HOP_RUN_UID|HOP_RUN_GID|APP_URL|SESSION_SECRET|DISCOVERY_SECRET|RESEND_API_KEY|REGISTRATION_MODE)=/' .env.example > "$hop_env_tmp"
|
||||
{
|
||||
printf 'HOP_BIND_IP=%s\n' "$hop_nas_ip"
|
||||
printf 'HOP_HOST_PORT=50189\nHOP_CONTAINER_PORT=50189\n'
|
||||
@@ -78,6 +78,24 @@ else
|
||||
printf '%s\n' '기존 .env를 유지합니다. 비밀값을 다시 생성하지 않습니다.'
|
||||
fi
|
||||
|
||||
# Match the NAS user so owner-only uploaded files remain readable in the NAS file manager.
|
||||
hop_run_uid=${SUDO_UID:-$(id -u)}
|
||||
hop_run_gid=${SUDO_GID:-$(id -g)}
|
||||
if [ "$hop_run_uid" = 0 ]; then
|
||||
printf '%s\n' 'NAS의 일반 사용자로 로그인한 뒤 sudo sh scripts/configure-nas.sh로 실행하세요.' >&2
|
||||
exit 1
|
||||
fi
|
||||
for hop_identity_key in HOP_RUN_UID HOP_RUN_GID; do
|
||||
if ! grep -Eq "^${hop_identity_key}=[0-9]+$" .env; then
|
||||
hop_env_tmp=$(mktemp .env.nas.XXXXXX)
|
||||
awk -v key="$hop_identity_key" 'index($0,key "=") != 1' .env > "$hop_env_tmp"
|
||||
case "$hop_identity_key" in HOP_RUN_UID) hop_identity_value=$hop_run_uid ;; HOP_RUN_GID) hop_identity_value=$hop_run_gid ;; esac
|
||||
printf '%s=%s\n' "$hop_identity_key" "$hop_identity_value" >> "$hop_env_tmp"
|
||||
mv "$hop_env_tmp" .env
|
||||
hop_env_tmp=''
|
||||
fi
|
||||
done
|
||||
|
||||
chmod 600 .env
|
||||
if grep -Eq '^USER_STORAGE_LIMIT_MB=10240$' .env; then
|
||||
hop_env_tmp=$(mktemp .env.nas.XXXXXX)
|
||||
@@ -95,10 +113,15 @@ if ! grep -Eq '^PROXY_DIAGNOSTIC_TOKEN=.{32,}$' .env; then
|
||||
hop_env_tmp=''
|
||||
unset hop_diagnostic_token
|
||||
fi
|
||||
sh scripts/prepare-nas-storage.sh
|
||||
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
|
||||
hop_yaml_tmp=''
|
||||
chmod 600 compose.nas.yaml
|
||||
# sudo writes configuration as root; return it to the SSH user for Git/Docker app access.
|
||||
if [ "$(id -u)" = 0 ]; then
|
||||
chown "$hop_run_uid:$hop_run_gid" .env compose.nas.yaml
|
||||
fi
|
||||
printf '\n%s\n' '완료. Docker 앱에서 프로젝트 이름 hop으로 아래 YAML을 불러오세요.'
|
||||
printf '%s/compose.nas.yaml\n' "$PWD"
|
||||
|
||||
38
scripts/prepare-nas-storage.sh
Normal file
38
scripts/prepare-nas-storage.sh
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/bin/sh
|
||||
# Prepare owner-only storage for the NAS user. Does not delete or reset any data.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
hop_storage_uid=$(sed -n 's/^HOP_RUN_UID=//p' .env)
|
||||
hop_storage_gid=$(sed -n 's/^HOP_RUN_GID=//p' .env)
|
||||
case "$hop_storage_uid:$hop_storage_gid" in
|
||||
*[!0-9:]*|:*|*:) printf '%s\n' 'HOP_RUN_UID/GID를 확인하세요.' >&2; exit 1 ;;
|
||||
esac
|
||||
if [ "$hop_storage_uid" = 0 ]; then
|
||||
printf '%s\n' '앱은 일반 NAS 사용자 UID로 실행해야 합니다.' >&2
|
||||
exit 1
|
||||
fi
|
||||
if docker inspect hop >/dev/null 2>&1; then
|
||||
if [ "$(docker inspect --format '{{.State.Running}}' hop)" = true ]; then
|
||||
printf '%s\n' 'Docker 앱에서 hop 프로젝트를 먼저 중지한 뒤 다시 실행하세요.' >&2
|
||||
exit 1
|
||||
fi
|
||||
if docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data/uploads"}}{{.Type}}{{end}}{{end}}' hop | grep -qx volume; then
|
||||
printf '%s\n' '기존 업로드 볼륨이 연결돼 있습니다. deploy.md의 저장소 전환 순서를 먼저 진행하세요.' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
mkdir -p uploads
|
||||
docker volume create --label com.docker.compose.project=hop --label com.docker.compose.volume=hop_database hop_hop_database >/dev/null
|
||||
docker run --rm --network none --user 0:0 \
|
||||
--mount type=volume,source=hop_hop_database,target=/database \
|
||||
--mount "type=bind,source=$PWD/uploads,target=/uploads" \
|
||||
alpine:3.22 sh -eu -c '
|
||||
chown -R "$1:$2" /database /uploads
|
||||
chmod 700 /database /uploads
|
||||
for folder in originals temporary chunks; do
|
||||
mkdir -p "/uploads/$folder"
|
||||
chmod 700 "/uploads/$folder"
|
||||
done
|
||||
chown -R "$1:$2" /uploads
|
||||
' sh "$hop_storage_uid" "$hop_storage_gid"
|
||||
printf '%s\n' '파일 저장소 준비 완료: hop/uploads (NAS 사용자 소유, 기존 파일 유지)'
|
||||
193
tests/docker/nas-config.mjs
Normal file
193
tests/docker/nas-config.mjs
Normal file
@@ -0,0 +1,193 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
cpSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
rmSync,
|
||||
realpathSync,
|
||||
} from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const root = resolve('.');
|
||||
const folder = mkdtempSync(resolve(tmpdir(), 'hop-nas-config-'));
|
||||
const realDocker = execFileSync('which', ['docker'], { encoding: 'utf8' }).trim();
|
||||
let checkedContainer, checkedVolume;
|
||||
try {
|
||||
mkdirSync(resolve(folder, 'scripts'));
|
||||
mkdirSync(resolve(folder, 'bin'));
|
||||
for (const name of ['configure-nas.sh', 'prepare-nas-storage.sh'])
|
||||
cpSync(resolve(root, 'scripts', name), resolve(folder, 'scripts', name));
|
||||
cpSync(resolve(root, 'compose.yaml'), resolve(folder, 'compose.yaml'));
|
||||
const envFile = resolve(folder, '.env');
|
||||
writeFileSync(
|
||||
envFile,
|
||||
[
|
||||
'HOP_BIND_IP=192.0.2.1',
|
||||
'HOP_HOST_PORT=50189',
|
||||
'HOP_CONTAINER_PORT=50189',
|
||||
'APP_URL=https://hop.test',
|
||||
'SESSION_SECRET=keep-test-session-value-1234567890',
|
||||
'DISCOVERY_SECRET=keep-test-discovery-value-1234567890',
|
||||
'PROXY_DIAGNOSTIC_TOKEN=keep-test-diagnostic-value-1234567890',
|
||||
'USER_STORAGE_LIMIT_MB=2048',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
const log = resolve(folder, 'docker-operations.log');
|
||||
writeFileSync(
|
||||
resolve(folder, 'bin', 'docker'),
|
||||
`#!/bin/sh
|
||||
set -eu
|
||||
case "$1" in
|
||||
compose) exec "$HOP_TEST_REAL_DOCKER" "$@" ;;
|
||||
inspect)
|
||||
if [ "$HOP_TEST_RUNNING" = 1 ]; then
|
||||
if [ "$2" = --format ]; then printf '%s\\n' true; else printf '%s\\n' '{}'; fi
|
||||
exit 0
|
||||
fi
|
||||
exit 1 ;;
|
||||
volume|run) printf '%s\\n' "$*" >> "$HOP_TEST_DOCKER_LOG" ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const environment = {
|
||||
...process.env,
|
||||
PATH: `${folder}/bin:${process.env.PATH}`,
|
||||
SUDO_UID: '12001',
|
||||
SUDO_GID: '12002',
|
||||
HOP_TEST_REAL_DOCKER: realDocker,
|
||||
HOP_TEST_DOCKER_LOG: log,
|
||||
HOP_TEST_RUNNING: '0',
|
||||
};
|
||||
execFileSync('sh', ['scripts/configure-nas.sh'], { cwd: folder, env: environment });
|
||||
const updated = readFileSync(envFile, 'utf8');
|
||||
assert(updated.includes('HOP_RUN_UID=12001\n'));
|
||||
assert(updated.includes('HOP_RUN_GID=12002\n'));
|
||||
assert(updated.includes('SESSION_SECRET=keep-test-session-value-1234567890'));
|
||||
assert(updated.includes('DISCOVERY_SECRET=keep-test-discovery-value-1234567890'));
|
||||
assert.equal(statSync(envFile).mode & 0o777, 0o600);
|
||||
const yaml = readFileSync(resolve(folder, 'compose.nas.yaml'), 'utf8');
|
||||
assert(!yaml.includes('keep-test-session-value'));
|
||||
const config = JSON.parse(
|
||||
execFileSync(
|
||||
realDocker,
|
||||
['compose', '-f', 'compose.nas.yaml', 'config', '--no-env-resolution', '--format', 'json'],
|
||||
{ cwd: folder, encoding: 'utf8' },
|
||||
),
|
||||
);
|
||||
assert.equal(config.services.hop.user, '12001:12002');
|
||||
const files = config.services.hop.volumes.find((mount) => mount.target === '/data/uploads');
|
||||
assert.equal(files.type, 'bind');
|
||||
assert.equal(files.source, realpathSync(resolve(folder, 'uploads')));
|
||||
assert.equal(files.bind.create_host_path, false);
|
||||
const database = config.services.hop.volumes.find((mount) => mount.target === '/data/database');
|
||||
assert.equal(database.volume.nocopy, true);
|
||||
assert.equal(config.volumes.hop_database.name, 'hop_hop_database');
|
||||
assert.equal(config.volumes.hop_uploads, undefined);
|
||||
assert(!readFileSync(log, 'utf8').includes('volume rm'));
|
||||
assert(readFileSync(log, 'utf8').includes('com.docker.compose.volume=hop_database'));
|
||||
const before = readFileSync(log, 'utf8');
|
||||
assert.throws(
|
||||
() =>
|
||||
execFileSync('sh', ['scripts/prepare-nas-storage.sh'], {
|
||||
cwd: folder,
|
||||
env: { ...environment, HOP_TEST_RUNNING: '1' },
|
||||
stdio: 'pipe',
|
||||
}),
|
||||
/Command failed/,
|
||||
);
|
||||
assert.equal(readFileSync(log, 'utf8'), before, 'running deployment refuses permission changes');
|
||||
assert(readFileSync(resolve(root, '.gitignore'), 'utf8').includes('/uploads/'));
|
||||
assert(readFileSync(resolve(root, '.dockerignore'), 'utf8').split('\n').includes('uploads'));
|
||||
if (process.env.HOP_CHECK_IMAGE) {
|
||||
const suffix = randomUUID().slice(0, 8),
|
||||
project = `hop-compose-check-${suffix}`;
|
||||
checkedContainer = project;
|
||||
checkedVolume = `${project}-db`;
|
||||
const run = (...args) => execFileSync(realDocker, args, { encoding: 'utf8', stdio: 'pipe' });
|
||||
run(
|
||||
'volume',
|
||||
'create',
|
||||
'--label',
|
||||
`com.docker.compose.project=${project}`,
|
||||
'--label',
|
||||
'com.docker.compose.volume=hop_database',
|
||||
checkedVolume,
|
||||
);
|
||||
run(
|
||||
'run',
|
||||
'--rm',
|
||||
'--network',
|
||||
'none',
|
||||
'--user',
|
||||
'0:0',
|
||||
'--mount',
|
||||
`type=volume,source=${checkedVolume},target=/database`,
|
||||
'--mount',
|
||||
`type=bind,source=${realpathSync(resolve(folder, 'uploads'))},target=/uploads`,
|
||||
process.env.HOP_CHECK_IMAGE,
|
||||
'sh',
|
||||
'-eu',
|
||||
'-c',
|
||||
'chown 12001:12002 /database /uploads; chmod 700 /database /uploads',
|
||||
);
|
||||
config.name = project;
|
||||
config.volumes.hop_database.name = checkedVolume;
|
||||
Object.assign(config.services.hop, {
|
||||
image: process.env.HOP_CHECK_IMAGE,
|
||||
container_name: checkedContainer,
|
||||
ports: [],
|
||||
network_mode: 'none',
|
||||
});
|
||||
delete config.services.hop.build;
|
||||
delete config.services.hop.networks;
|
||||
delete config.networks;
|
||||
const checkYaml = resolve(folder, 'compose.check.json');
|
||||
writeFileSync(checkYaml, JSON.stringify(config));
|
||||
run('compose', '-f', checkYaml, 'up', '-d', '--no-build');
|
||||
run(
|
||||
'exec',
|
||||
checkedContainer,
|
||||
'node',
|
||||
'-e',
|
||||
"(async()=>{for(let i=0;i<60;i++){try{if((await fetch('http://127.0.0.1:50189/api/health')).ok)return}catch{}await new Promise(r=>setTimeout(r,250));}process.exit(1)})()",
|
||||
);
|
||||
const mounts = JSON.parse(run('inspect', '--format', '{{json .Mounts}}', checkedContainer));
|
||||
const bind = mounts.find((mount) => mount.Destination === '/data/uploads');
|
||||
assert.equal(bind.Type, 'bind');
|
||||
const hostSource =
|
||||
process.platform === 'darwin' ? bind.Source.replace(/^\/host_mnt/, '') : bind.Source;
|
||||
assert.equal(realpathSync(hostSource), realpathSync(resolve(folder, 'uploads')));
|
||||
assert.equal(
|
||||
run('inspect', '--format', '{{.Config.User}}', checkedContainer).trim(),
|
||||
'12001:12002',
|
||||
);
|
||||
assert.equal(
|
||||
run('inspect', '--format', '{{json .HostConfig.PortBindings}}', checkedContainer).trim(),
|
||||
'{}',
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
'PASS: NAS UID/GID, secret preservation, private env file, absolute uploads bind path, no DB copy-up, stopped-container guard, no implicit data deletion, uploads excluded from Git/build. Compose parsed with real Docker; storage mutations isolated through a test shim.',
|
||||
);
|
||||
} finally {
|
||||
if (checkedContainer) {
|
||||
try {
|
||||
execFileSync(realDocker, ['rm', '-f', checkedContainer], { stdio: 'pipe' });
|
||||
} catch {}
|
||||
}
|
||||
if (checkedVolume) {
|
||||
try {
|
||||
execFileSync(realDocker, ['volume', 'rm', checkedVolume], { stdio: 'pipe' });
|
||||
} catch {}
|
||||
}
|
||||
rmSync(folder, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,17 +1,41 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { randomBytes, randomInt, randomUUID } from 'node:crypto';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
const suffix = randomUUID().slice(0, 8),
|
||||
name = `hop-check-${suffix}`,
|
||||
volume = `hop-check-db-${suffix}`,
|
||||
uploads = `hop-check-files-${suffix}`;
|
||||
const port = String(randomInt(20000, 60000)); // Isolated container only; no host port is published.
|
||||
const bindUploads = process.env.HOP_CHECK_BIND_UPLOADS === '1';
|
||||
const folder = bindUploads ? mkdtempSync(resolve(tmpdir(), 'hop-bind-files-')) : null;
|
||||
const runtimeUser = bindUploads ? '12001:12001' : '1000:1000';
|
||||
const docker = (...args) =>
|
||||
execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||
const node = (code) => docker('exec', name, 'node', '--input-type=module', '-e', code);
|
||||
try {
|
||||
docker('volume', 'create', volume);
|
||||
docker('volume', 'create', uploads);
|
||||
if (bindUploads) {
|
||||
docker(
|
||||
'run',
|
||||
'--rm',
|
||||
'--network',
|
||||
'none',
|
||||
'--user',
|
||||
'0:0',
|
||||
'-v',
|
||||
`${volume}:/database`,
|
||||
'--mount',
|
||||
`type=bind,source=${folder},target=/uploads`,
|
||||
process.env.HOP_CHECK_IMAGE ?? 'hop-phase1-check:local',
|
||||
'sh',
|
||||
'-eu',
|
||||
'-c',
|
||||
`chown 12001:12001 /database /uploads; chmod 700 /database /uploads`,
|
||||
);
|
||||
} else docker('volume', 'create', uploads);
|
||||
docker(
|
||||
'run',
|
||||
'-d',
|
||||
@@ -19,6 +43,8 @@ try {
|
||||
name,
|
||||
'--network',
|
||||
'none',
|
||||
'--user',
|
||||
runtimeUser,
|
||||
'-e',
|
||||
'NODE_ENV=production',
|
||||
'-e',
|
||||
@@ -31,10 +57,12 @@ try {
|
||||
`SESSION_SECRET=${randomBytes(32).toString('hex')}`,
|
||||
'-e',
|
||||
`DISCOVERY_SECRET=${randomBytes(32).toString('hex')}`,
|
||||
'-v',
|
||||
`${volume}:/data/database`,
|
||||
'-v',
|
||||
`${uploads}:/data/uploads`,
|
||||
...(bindUploads
|
||||
? ['--mount', `type=volume,source=${volume},target=/data/database,volume-nocopy`]
|
||||
: ['-v', `${volume}:/data/database`]),
|
||||
...(bindUploads
|
||||
? ['--mount', `type=bind,source=${folder},target=/data/uploads`]
|
||||
: ['-v', `${uploads}:/data/uploads`]),
|
||||
process.env.HOP_CHECK_IMAGE ?? 'hop-phase1-check:local',
|
||||
);
|
||||
const wait = () =>
|
||||
@@ -80,9 +108,41 @@ try {
|
||||
`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');
|
||||
if (bindUploads) {
|
||||
assert.equal(docker('inspect', '--format', '{{.Config.User}}', name), runtimeUser);
|
||||
const mode = JSON.parse(
|
||||
node(
|
||||
`import {statSync} from 'node:fs';const s=statSync('/data/uploads/originals');console.log(JSON.stringify({uid:s.uid,gid:s.gid,mode:s.mode&511}));`,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(mode, { uid: 12001, gid: 12001, mode: 0o700 });
|
||||
const originals = resolve(folder, 'originals');
|
||||
const stored = readdirSync(originals).find(
|
||||
(key) => readFileSync(resolve(originals, key)).toString() === 'Docker file persistence',
|
||||
);
|
||||
assert(stored, 'uploaded file is visible in the host project directory');
|
||||
unlinkSync(resolve(originals, stored));
|
||||
const missing = JSON.parse(
|
||||
node(
|
||||
`const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});console.log(JSON.stringify({status:r.status,body:await r.json()}));`,
|
||||
),
|
||||
);
|
||||
assert.equal(missing.status, 404);
|
||||
assert.equal(missing.body.code, 'FILE_NOT_FOUND');
|
||||
const visible = JSON.parse(
|
||||
node(
|
||||
`const r=await fetch('http://127.0.0.1:${port}/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});console.log(JSON.stringify((await r.json()).items));`,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
visible.some((item) => item.accessId === fileState.accessId),
|
||||
false,
|
||||
);
|
||||
assert(visible.some((item) => item.file?.name === 'resume.bin'));
|
||||
}
|
||||
assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}');
|
||||
console.log(
|
||||
'PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.',
|
||||
`PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' }));
|
||||
@@ -92,8 +152,9 @@ try {
|
||||
try {
|
||||
docker('rm', '-f', name);
|
||||
} catch {}
|
||||
for (const v of [volume, uploads])
|
||||
for (const v of [volume, ...(bindUploads ? [] : [uploads])])
|
||||
try {
|
||||
docker('volume', 'rm', v);
|
||||
} catch {}
|
||||
if (folder) rmSync(folder, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -339,6 +339,25 @@ try {
|
||||
);
|
||||
}
|
||||
await page.screenshot({ path: 'tests/e2e/login-mobile.png' });
|
||||
await page.route('**/api/auth/login', (route) => route.abort('failed'));
|
||||
await page.getByLabel('이메일', { exact: true }).fill('offline@hop.test');
|
||||
await page.getByLabel('비밀번호', { exact: true }).fill('password-1234');
|
||||
await page.locator('.account-auth').getByRole('button', { name: '로그인', exact: true }).click();
|
||||
const networkToast = page.locator('.error-toast');
|
||||
await networkToast.filter({ hasText: '서버에 연결하지 못했어요' }).waitFor();
|
||||
const toastSpacing = await networkToast.evaluate((el) => {
|
||||
const box = el.getBoundingClientRect(),
|
||||
text = el.querySelector('span').getBoundingClientRect(),
|
||||
icon = el.querySelector('button svg').getBoundingClientRect();
|
||||
return { left: text.left - box.left, right: box.right - icon.right };
|
||||
});
|
||||
assert(Math.abs(toastSpacing.left - toastSpacing.right) < 1, '모바일 토스트 양쪽 여백');
|
||||
const toastClose = await networkToast.getByRole('button', { name: '오류 닫기' }).boundingBox();
|
||||
assert(toastClose.width >= 44 && toastClose.height >= 44, '닫기 버튼 터치 영역 유지');
|
||||
await page.screenshot({ path: 'tests/e2e/error-toast-mobile.png' });
|
||||
await networkToast.getByRole('button', { name: '오류 닫기' }).click();
|
||||
await networkToast.waitFor({ state: 'hidden' });
|
||||
await page.unroute('**/api/auth/login');
|
||||
await more.click();
|
||||
const menuBounds = await page.getByRole('menu', { name: '더보기', exact: true }).boundingBox();
|
||||
assert(
|
||||
|
||||
@@ -384,9 +384,13 @@ try {
|
||||
window.__nativeOpen = window.open;
|
||||
window.open = () => null;
|
||||
});
|
||||
await second.evaluate(() => {
|
||||
window.__hopClipboard = 'https://example.com/blocked';
|
||||
});
|
||||
await second.locator('.peer-card').filter({ hasText: receiverCode }).click();
|
||||
|
||||
await second.locator('.current-url, .current-text').fill('https://example.com/blocked');
|
||||
await second.waitForFunction(
|
||||
() => document.querySelector('.current-url')?.value === 'https://example.com/blocked',
|
||||
);
|
||||
await second.getByRole('button', { name: '전송하기', exact: true }).click();
|
||||
await urlReceiver.getByRole('button', { name: '수락', exact: true }).click();
|
||||
await urlReceiver.locator('.delivery-blocked').waitFor();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from '@playwright/test';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { mkdtemp, rm, readdir, unlink } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { loadConfig } from '../../apps/server/src/config.ts';
|
||||
@@ -80,6 +80,10 @@ try {
|
||||
await auth.getByLabel('비밀번호', { exact: true }).fill('1234');
|
||||
await auth.getByRole('button', { name: '로그인', exact: true }).click();
|
||||
await page.locator('.account-workspace').waitFor();
|
||||
assert.deepEqual(
|
||||
(await page.getByRole('tab').allTextContents()).map((text) => text.trim()),
|
||||
['받은 항목', '임시 저장'],
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByRole('tab', { name: '받은 항목', exact: true }).getAttribute('aria-selected'),
|
||||
'true',
|
||||
@@ -109,6 +113,11 @@ try {
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await preferences.locator('select,input').count(), 0, '항목 목록을 먼저 표시');
|
||||
assert.equal(await preferences.locator('.preference-item').count(), 2);
|
||||
assert.equal(
|
||||
await preferences.getByRole('button', { name: '이메일 변경', exact: true }).count(),
|
||||
0,
|
||||
'이메일 변경 버튼 숨김',
|
||||
);
|
||||
assert.equal(
|
||||
await preferences.locator('.preference-item').first().locator('p').innerText(),
|
||||
email,
|
||||
@@ -146,20 +155,19 @@ try {
|
||||
}
|
||||
await page.screenshot({ path: `tests/e2e/account-password-${device}.png` });
|
||||
await preferences.getByRole('button', { name: '계정 설정 목록으로', exact: true }).click();
|
||||
await preferences.getByRole('button', { name: '이메일 변경', exact: true }).click();
|
||||
await preferences.getByRole('button', { name: '비밀번호 변경', exact: true }).click();
|
||||
assert.equal(
|
||||
await preferences.getByLabel('현재 비밀번호', { exact: true }).inputValue(),
|
||||
'',
|
||||
'항목 전환 시 비밀번호 초안 제거',
|
||||
);
|
||||
await preferences.getByLabel('새 이메일', { exact: true }).fill('new@example.com');
|
||||
await page.screenshot({ path: `tests/e2e/account-email-${device}.png` });
|
||||
await preferences.getByLabel('새 비밀번호', { exact: true }).fill('draft1234');
|
||||
await page.keyboard.press('Escape');
|
||||
await preferences.waitFor({ state: 'hidden' });
|
||||
await page.getByRole('button', { name: '계정 설정', exact: true }).click();
|
||||
await preferences.getByRole('button', { name: '이메일 변경', exact: true }).click();
|
||||
await preferences.getByRole('button', { name: '비밀번호 변경', exact: true }).click();
|
||||
assert.equal(
|
||||
await preferences.getByLabel('새 이메일', { exact: true }).inputValue(),
|
||||
await preferences.getByLabel('새 비밀번호', { exact: true }).inputValue(),
|
||||
'',
|
||||
'모달 닫기 시 개인정보 초안 제거',
|
||||
);
|
||||
@@ -220,6 +228,10 @@ try {
|
||||
await sender.locator('.current-url, .current-text').fill('https://example.com/saved');
|
||||
await sender.getByRole('button', { name: '저장하기', exact: true }).click();
|
||||
await sender.locator('.item-card').filter({ hasText: '나중에 읽을 글' }).waitFor();
|
||||
assert.match(
|
||||
await sender.locator('.item-footer > span').first().innerText(),
|
||||
/^보관 만료 · .+까지$/,
|
||||
);
|
||||
await sender.getByRole('button', { name: '메모 수정', exact: true }).click();
|
||||
await sender.getByLabel('한 줄 메모 수정').fill('업무 링크');
|
||||
await sender.locator('.note-editor').getByRole('button', { name: '저장', exact: true }).click();
|
||||
@@ -300,12 +312,74 @@ try {
|
||||
);
|
||||
const saved = await sender.evaluate(async () => (await (await fetch('/api/vault')).json()).items);
|
||||
assert(saved.every((item) => item.expiresAt - item.createdAt <= 7 * 86400000));
|
||||
await navigate(receiver, '기기');
|
||||
await receiver.locator('.peer-card').first().click();
|
||||
await receiver.getByRole('button', { name: '보관함에서 가져오기', exact: true }).click();
|
||||
await receiver.locator('.vault-choice').filter({ hasText: 'saved.txt' }).click();
|
||||
await receiver.locator('.current-vault').waitFor();
|
||||
assert.equal(await receiver.locator('.content-submit').isEnabled(), true);
|
||||
let removed = false;
|
||||
for (const key of await readdir(runtime.service.storage.originals)) {
|
||||
const path = resolve(runtime.service.storage.originals, key);
|
||||
if ((await readFile(path)).equals(bytes)) {
|
||||
await unlink(path);
|
||||
removed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert(removed, '보관함 원본을 서버 저장소에서 직접 삭제');
|
||||
let unexpectedDownloads = 0;
|
||||
sender.on('download', () => unexpectedDownloads++);
|
||||
await fileRow.getByRole('button', { name: '다운로드', exact: true }).click();
|
||||
await sender.locator('.error-toast').filter({ hasText: '다시 전송' }).waitFor();
|
||||
const toastAlignment = await sender.locator('.error-toast').evaluate((element) => {
|
||||
const toast = element.getBoundingClientRect(),
|
||||
text = element.querySelector('span').getBoundingClientRect(),
|
||||
icon = element.querySelector('button svg').getBoundingClientRect();
|
||||
return {
|
||||
left: text.left - toast.left,
|
||||
right: toast.right - icon.right,
|
||||
textCenter: text.top + text.height / 2,
|
||||
iconCenter: icon.top + icon.height / 2,
|
||||
};
|
||||
});
|
||||
assert(
|
||||
Math.abs(toastAlignment.left - toastAlignment.right) < 1,
|
||||
'오류 텍스트와 X 아이콘의 양쪽 여백 균형',
|
||||
);
|
||||
assert(
|
||||
Math.abs(toastAlignment.textCenter - toastAlignment.iconCenter) < 1,
|
||||
'오류 텍스트와 X 아이콘의 세로 정렬',
|
||||
);
|
||||
await sender.screenshot({ path: 'tests/e2e/account-missing-file.png', fullPage: true });
|
||||
assert.equal(unexpectedDownloads, 0, '없는 파일을 다운로드하기 전에 안내');
|
||||
await fileRow.waitFor({ state: 'hidden' });
|
||||
await receiver.locator('.current-vault').waitFor({ state: 'hidden' });
|
||||
await receiver
|
||||
.locator('.vault-choice')
|
||||
.filter({ hasText: 'saved.txt' })
|
||||
.waitFor({ state: 'hidden' });
|
||||
assert.equal(
|
||||
await receiver.locator('.content-submit').isDisabled(),
|
||||
true,
|
||||
'다른 기기의 열린 전송 선택도 삭제 알림으로 해제',
|
||||
);
|
||||
await receiver.getByRole('button', { name: '전송 화면 닫기', exact: true }).click();
|
||||
await navigate(receiver, '받은 항목');
|
||||
for (const page of [sender, receiver])
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
async () => (await (await fetch('/api/account/storage')).json()).usedBytes,
|
||||
),
|
||||
0,
|
||||
);
|
||||
await sender.locator('.error-toast').waitFor({ state: 'hidden', timeout: 8000 });
|
||||
await receiver.getByRole('button', { name: '로그아웃', exact: true }).click();
|
||||
await receiver.locator('.account-auth').waitFor();
|
||||
assert(!(await receiver.locator('body').innerText()).includes('https://example.com/saved'));
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS: 받은 항목 기본 화면·보관 안내, PC/모바일 계정 설정 목록·항목별 폼·간격·16px 입력·초안 제거·키보드 탐색, 가입·인증·로그인, 날짜별 기록, 직접 링크/파일 저장과 메모 수정, 보관함 자료의 수락 후 자동 열기/다운로드, 파일 재업로드 없음, 7일 만료, 삭제/복원, 로그아웃 권한 격리. 실제 Resend 발송은 포함하지 않음.',
|
||||
'PASS: 받은 항목 기본 화면·보관 안내, PC/모바일 계정 설정·입력·키보드 탐색, 가입·인증·로그인, 날짜별 기록, 직접 링크/파일 저장·메모·수락 후 자동 열기/다운로드, 파일 재업로드 없음, 7일 만료·삭제/복원·로그아웃 권한 격리, 원본 수동 삭제 시 다운로드 전 안내·양쪽 목록/용량 정리·열린 전송 선택 해제. 실제 Resend 발송은 포함하지 않음.',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
Reference in New Issue
Block a user