Initial hop. implementation and UGREEN NAS deployment guide

This commit is contained in:
2026-10-12 13:37:50 +09:00
commit 439c2ae61b
93 changed files with 42434 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({ schema: './src/db/schema.ts', out: './drizzle', dialect: 'sqlite' });

View File

@@ -0,0 +1,126 @@
CREATE TABLE `item_access` (
`id` text PRIMARY KEY NOT NULL,
`itemId` text NOT NULL,
`guestId` text NOT NULL,
`role` text NOT NULL,
`read` integer DEFAULT false NOT NULL,
`deletedAt` integer,
FOREIGN KEY (`itemId`) REFERENCES `items`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `access_subject_role` ON `item_access` (`itemId`,`guestId`,`role`);--> statement-breakpoint
CREATE INDEX `access_guest` ON `item_access` (`guestId`,`deletedAt`);--> statement-breakpoint
CREATE TABLE `transfer_batches` (
`id` text PRIMARY KEY NOT NULL,
`requestId` text NOT NULL,
`status` text NOT NULL,
`storedAt` integer,
FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `transfer_batches_requestId_unique` ON `transfer_batches` (`requestId`);--> statement-breakpoint
CREATE TABLE `guest_blocks` (
`id` text PRIMARY KEY NOT NULL,
`guestId` text NOT NULL,
`targetGuestId` text NOT NULL,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`targetGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `guest_block_pair` ON `guest_blocks` (`guestId`,`targetGuestId`);--> statement-breakpoint
CREATE TABLE `browser_devices` (
`id` text PRIMARY KEY NOT NULL,
`guestId` text NOT NULL,
`name` text NOT NULL,
`code` text NOT NULL,
`discoverable` integer DEFAULT true NOT NULL,
`lastSeen` integer NOT NULL,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "device_name_length" CHECK(length("browser_devices"."name") between 1 and 40)
);
--> statement-breakpoint
CREATE UNIQUE INDEX `browser_devices_guestId_unique` ON `browser_devices` (`guestId`);--> statement-breakpoint
CREATE UNIQUE INDEX `browser_devices_code_unique` ON `browser_devices` (`code`);--> statement-breakpoint
CREATE TABLE `guest_sessions` (
`id` text PRIMARY KEY NOT NULL,
`tokenHash` text NOT NULL,
`expiresAt` integer NOT NULL,
`revokedAt` integer,
`createdAt` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `guest_sessions_tokenHash_unique` ON `guest_sessions` (`tokenHash`);--> statement-breakpoint
CREATE INDEX `guest_expiry` ON `guest_sessions` (`expiresAt`);--> statement-breakpoint
CREATE TABLE `idempotency_records` (
`id` text PRIMARY KEY NOT NULL,
`guestId` text NOT NULL,
`operation` text NOT NULL,
`key` text NOT NULL,
`payloadHash` text NOT NULL,
`response` text NOT NULL,
`createdAt` integer NOT NULL,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idempotency_scope` ON `idempotency_records` (`guestId`,`operation`,`key`);--> statement-breakpoint
CREATE TABLE `items` (
`id` text PRIMARY KEY NOT NULL,
`batchId` text NOT NULL,
`kind` text NOT NULL,
`text` text,
`url` text,
`createdAt` integer NOT NULL,
`expiresAt` integer NOT NULL,
FOREIGN KEY (`batchId`) REFERENCES `transfer_batches`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "item_payload" CHECK(("items"."kind" = 'text' AND "items"."text" IS NOT NULL AND "items"."url" IS NULL) OR ("items"."kind" = 'url' AND "items"."url" IS NOT NULL AND "items"."text" IS NULL))
);
--> statement-breakpoint
CREATE INDEX `items_expiry` ON `items` (`expiresAt`);--> statement-breakpoint
CREATE TABLE `space_members` (
`id` text PRIMARY KEY NOT NULL,
`spaceId` text NOT NULL,
`deviceId` text NOT NULL,
`status` text NOT NULL,
`joinedAt` integer NOT NULL,
FOREIGN KEY (`spaceId`) REFERENCES `connection_spaces`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`deviceId`) REFERENCES `browser_devices`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `space_device` ON `space_members` (`spaceId`,`deviceId`);--> statement-breakpoint
CREATE TABLE `transfer_requests` (
`id` text PRIMARY KEY NOT NULL,
`mode` text DEFAULT 'local' NOT NULL,
`senderGuestId` text NOT NULL,
`recipientGuestId` text NOT NULL,
`kind` text NOT NULL,
`byteSize` integer NOT NULL,
`count` integer NOT NULL,
`status` text NOT NULL,
`createdAt` integer NOT NULL,
`expiresAt` integer NOT NULL,
`acceptedAt` integer,
`startBy` integer,
FOREIGN KEY (`senderGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`recipientGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "request_different_guests" CHECK("transfer_requests"."senderGuestId" <> "transfer_requests"."recipientGuestId")
);
--> statement-breakpoint
CREATE INDEX `requests_sender_time` ON `transfer_requests` (`senderGuestId`,`createdAt`);--> statement-breakpoint
CREATE INDEX `requests_recipient_time` ON `transfer_requests` (`recipientGuestId`,`createdAt`);--> statement-breakpoint
CREATE INDEX `requests_expiry` ON `transfer_requests` (`status`,`expiresAt`);--> statement-breakpoint
CREATE TABLE `connection_spaces` (
`id` text PRIMARY KEY NOT NULL,
`creatorGuestId` text NOT NULL,
`tokenHash` text NOT NULL,
`codeHash` text NOT NULL,
`approvalRequired` integer NOT NULL,
`expiresAt` integer NOT NULL,
`closedAt` integer,
`createdAt` integer NOT NULL,
FOREIGN KEY (`creatorGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `connection_spaces_tokenHash_unique` ON `connection_spaces` (`tokenHash`);--> statement-breakpoint
CREATE UNIQUE INDEX `connection_spaces_codeHash_unique` ON `connection_spaces` (`codeHash`);--> statement-breakpoint
CREATE INDEX `space_expiry` ON `connection_spaces` (`expiresAt`);

View File

@@ -0,0 +1 @@
ALTER TABLE `transfer_requests` ADD `rejectedAt` integer;

View File

@@ -0,0 +1,71 @@
CREATE TABLE `deletion_targets` (
`id` text PRIMARY KEY NOT NULL,
`operationId` text NOT NULL,
`accessId` text NOT NULL,
FOREIGN KEY (`operationId`) REFERENCES `deletion_operations`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `deletion_operations` (
`id` text PRIMARY KEY NOT NULL,
`guestId` text NOT NULL,
`undoUntil` integer NOT NULL,
`createdAt` integer NOT NULL,
`restoredAt` integer,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `files` (
`id` text PRIMARY KEY NOT NULL,
`requestId` text NOT NULL,
`ownerGuestId` text NOT NULL,
`position` integer NOT NULL,
`storageKey` text NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`size` integer NOT NULL,
`checksum` text,
`status` text NOT NULL,
`uploadId` text,
`updatedAt` integer NOT NULL,
FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`ownerGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "file_nonnegative_size" CHECK("files"."size" >= 0)
);
--> statement-breakpoint
CREATE UNIQUE INDEX `files_storageKey_unique` ON `files` (`storageKey`);--> statement-breakpoint
CREATE UNIQUE INDEX `request_file_position` ON `files` (`requestId`,`position`);--> statement-breakpoint
CREATE INDEX `files_owner_state` ON `files` (`ownerGuestId`,`status`);--> statement-breakpoint
CREATE TABLE `quota_reservations` (
`id` text PRIMARY KEY NOT NULL,
`fileId` text NOT NULL,
`guestId` text NOT NULL,
`bytes` integer NOT NULL,
`expiresAt` integer NOT NULL,
FOREIGN KEY (`fileId`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "reservation_nonnegative" CHECK("quota_reservations"."bytes" >= 0)
);
--> statement-breakpoint
CREATE UNIQUE INDEX `quota_reservations_fileId_unique` ON `quota_reservations` (`fileId`);--> statement-breakpoint
CREATE INDEX `quota_guest_expiry` ON `quota_reservations` (`guestId`,`expiresAt`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_items` (
`id` text PRIMARY KEY NOT NULL,
`batchId` text NOT NULL,
`kind` text NOT NULL,
`text` text,
`url` text,
`fileId` text,
`createdAt` integer NOT NULL,
`expiresAt` integer NOT NULL,
FOREIGN KEY (`batchId`) REFERENCES `transfer_batches`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`fileId`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "item_payload" CHECK(("__new_items"."kind" = 'text' AND "__new_items"."text" IS NOT NULL AND "__new_items"."url" IS NULL AND "__new_items"."fileId" IS NULL) OR ("__new_items"."kind" = 'url' AND "__new_items"."url" IS NOT NULL AND "__new_items"."text" IS NULL AND "__new_items"."fileId" IS NULL) OR ("__new_items"."kind" = 'file' AND "__new_items"."fileId" IS NOT NULL AND "__new_items"."text" IS NULL AND "__new_items"."url" IS NULL))
);
--> statement-breakpoint
INSERT INTO `__new_items`("id", "batchId", "kind", "text", "url", "fileId", "createdAt", "expiresAt") SELECT "id", "batchId", "kind", "text", "url", NULL, "createdAt", "expiresAt" FROM `items`;--> statement-breakpoint
DROP TABLE `items`;--> statement-breakpoint
ALTER TABLE `__new_items` RENAME TO `items`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `items_expiry` ON `items` (`expiresAt`);--> statement-breakpoint
ALTER TABLE `transfer_requests` ADD `manifest` text;

View File

@@ -0,0 +1 @@
ALTER TABLE `files` ADD `uploadKey` text;

View File

@@ -0,0 +1,191 @@
CREATE TABLE `account_sessions` (
`id` text PRIMARY KEY NOT NULL,
`userId` text NOT NULL,
`tokenHash` text NOT NULL,
`createdAt` integer NOT NULL,
`lastSeen` integer NOT NULL,
`expiresAt` integer NOT NULL,
`revokedAt` integer,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `account_sessions_tokenHash_unique` ON `account_sessions` (`tokenHash`);--> statement-breakpoint
CREATE INDEX `account_session_user` ON `account_sessions` (`userId`,`expiresAt`);--> statement-breakpoint
CREATE TABLE `account_tokens` (
`id` text PRIMARY KEY NOT NULL,
`userId` text NOT NULL,
`purpose` text NOT NULL,
`tokenHash` text NOT NULL,
`value` text,
`createdAt` integer NOT NULL,
`expiresAt` integer NOT NULL,
`usedAt` integer,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `account_tokens_tokenHash_unique` ON `account_tokens` (`tokenHash`);--> statement-breakpoint
CREATE INDEX `account_token_expiry` ON `account_tokens` (`expiresAt`);--> statement-breakpoint
CREATE TABLE `user_rules` (
`id` text PRIMARY KEY NOT NULL,
`userId` text NOT NULL,
`targetUserId` text NOT NULL,
`action` text NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`targetUserId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "rule_not_self" CHECK("user_rules"."userId" <> "user_rules"."targetUserId")
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_rule_pair` ON `user_rules` (`userId`,`targetUserId`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text,
`displayName` text NOT NULL,
`passwordHash` text,
`role` text DEFAULT 'user' NOT NULL,
`status` text NOT NULL,
`verifiedAt` integer,
`createdAt` integer NOT NULL,
`receivePolicy` text DEFAULT 'requests' NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_transfer_requests` (
`id` text PRIMARY KEY NOT NULL,
`mode` text DEFAULT 'local' NOT NULL,
`senderGuestId` text,
`recipientGuestId` text,
`senderUserId` text,
`recipientUserId` text,
`kind` text NOT NULL,
`byteSize` integer NOT NULL,
`manifest` text,
`count` integer NOT NULL,
`status` text NOT NULL,
`createdAt` integer NOT NULL,
`expiresAt` integer NOT NULL,
`acceptedAt` integer,
`rejectedAt` integer,
`startBy` integer,
FOREIGN KEY (`senderGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`recipientGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`senderUserId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`recipientUserId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "request_different_guests" CHECK("__new_transfer_requests"."mode" <> 'local' OR "__new_transfer_requests"."senderGuestId" <> "__new_transfer_requests"."recipientGuestId"),
CONSTRAINT "request_sender_subject" CHECK(("__new_transfer_requests"."senderGuestId" IS NOT NULL) + ("__new_transfer_requests"."senderUserId" IS NOT NULL) = 1),
CONSTRAINT "request_recipient_subject" CHECK(("__new_transfer_requests"."recipientGuestId" IS NOT NULL) + ("__new_transfer_requests"."recipientUserId" IS NOT NULL) = 1),
CONSTRAINT "request_mode_subjects" CHECK(("__new_transfer_requests"."mode" = 'local' AND "__new_transfer_requests"."senderGuestId" IS NOT NULL AND "__new_transfer_requests"."recipientGuestId" IS NOT NULL) OR ("__new_transfer_requests"."mode" = 'email' AND "__new_transfer_requests"."senderUserId" IS NOT NULL AND "__new_transfer_requests"."recipientUserId" IS NOT NULL))
);
--> statement-breakpoint
INSERT INTO `__new_transfer_requests`("id", "mode", "senderGuestId", "recipientGuestId", "senderUserId", "recipientUserId", "kind", "byteSize", "manifest", "count", "status", "createdAt", "expiresAt", "acceptedAt", "rejectedAt", "startBy") SELECT "id", "mode", "senderGuestId", "recipientGuestId", NULL, NULL, "kind", "byteSize", "manifest", "count", "status", "createdAt", "expiresAt", "acceptedAt", "rejectedAt", "startBy" FROM `transfer_requests`;--> statement-breakpoint
DROP TABLE `transfer_requests`;--> statement-breakpoint
ALTER TABLE `__new_transfer_requests` RENAME TO `transfer_requests`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `requests_sender_time` ON `transfer_requests` (`senderGuestId`,`createdAt`);--> statement-breakpoint
CREATE INDEX `requests_recipient_time` ON `transfer_requests` (`recipientGuestId`,`createdAt`);--> statement-breakpoint
CREATE INDEX `requests_expiry` ON `transfer_requests` (`status`,`expiresAt`);--> statement-breakpoint
CREATE INDEX `requests_sender_user` ON `transfer_requests` (`senderUserId`,`createdAt`);--> statement-breakpoint
CREATE INDEX `requests_recipient_user` ON `transfer_requests` (`recipientUserId`,`createdAt`);--> statement-breakpoint
CREATE TABLE `__new_item_access` (
`id` text PRIMARY KEY NOT NULL,
`itemId` text NOT NULL,
`userId` text,
`guestId` text,
`role` text NOT NULL,
`read` integer DEFAULT false NOT NULL,
`deletedAt` integer,
`favorite` integer DEFAULT false NOT NULL,
FOREIGN KEY (`itemId`) REFERENCES `items`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "access_subject" CHECK(("__new_item_access"."guestId" IS NOT NULL) + ("__new_item_access"."userId" IS NOT NULL) = 1)
);
--> statement-breakpoint
INSERT INTO `__new_item_access`("id", "itemId", "userId", "guestId", "role", "read", "deletedAt", "favorite") SELECT "id", "itemId", NULL, "guestId", "role", "read", "deletedAt", 0 FROM `item_access`;--> statement-breakpoint
DROP TABLE `item_access`;--> statement-breakpoint
ALTER TABLE `__new_item_access` RENAME TO `item_access`;--> statement-breakpoint
CREATE UNIQUE INDEX `access_subject_role` ON `item_access` (`itemId`,`guestId`,`role`);--> statement-breakpoint
CREATE UNIQUE INDEX `access_account_role` ON `item_access` (`itemId`,`userId`,`role`);--> statement-breakpoint
CREATE INDEX `access_account` ON `item_access` (`userId`,`deletedAt`);--> statement-breakpoint
CREATE INDEX `access_guest` ON `item_access` (`guestId`,`deletedAt`);--> statement-breakpoint
CREATE TABLE `__new_deletion_operations` (
`id` text PRIMARY KEY NOT NULL,
`userId` text,
`guestId` text,
`undoUntil` integer NOT NULL,
`createdAt` integer NOT NULL,
`restoredAt` integer,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "deletion_subject" CHECK(("__new_deletion_operations"."guestId" IS NOT NULL) + ("__new_deletion_operations"."userId" IS NOT NULL) = 1)
);
--> statement-breakpoint
INSERT INTO `__new_deletion_operations`("id", "userId", "guestId", "undoUntil", "createdAt", "restoredAt") SELECT "id", NULL, "guestId", "undoUntil", "createdAt", "restoredAt" FROM `deletion_operations`;--> statement-breakpoint
DROP TABLE `deletion_operations`;--> statement-breakpoint
ALTER TABLE `__new_deletion_operations` RENAME TO `deletion_operations`;--> statement-breakpoint
CREATE TABLE `__new_files` (
`id` text PRIMARY KEY NOT NULL,
`requestId` text NOT NULL,
`ownerGuestId` text,
`ownerUserId` text,
`position` integer NOT NULL,
`storageKey` text NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`size` integer NOT NULL,
`checksum` text,
`status` text NOT NULL,
`uploadId` text,
`uploadKey` text,
`updatedAt` integer NOT NULL,
FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`ownerGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`ownerUserId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "file_nonnegative_size" CHECK("__new_files"."size" >= 0),
CONSTRAINT "file_owner_subject" CHECK(("__new_files"."ownerGuestId" IS NOT NULL) + ("__new_files"."ownerUserId" IS NOT NULL) = 1)
);
--> statement-breakpoint
INSERT INTO `__new_files`("id", "requestId", "ownerGuestId", "ownerUserId", "position", "storageKey", "name", "type", "size", "checksum", "status", "uploadId", "uploadKey", "updatedAt") SELECT "id", "requestId", "ownerGuestId", NULL, "position", "storageKey", "name", "type", "size", "checksum", "status", "uploadId", "uploadKey", "updatedAt" FROM `files`;--> statement-breakpoint
DROP TABLE `files`;--> statement-breakpoint
ALTER TABLE `__new_files` RENAME TO `files`;--> statement-breakpoint
CREATE UNIQUE INDEX `files_storageKey_unique` ON `files` (`storageKey`);--> statement-breakpoint
CREATE UNIQUE INDEX `request_file_position` ON `files` (`requestId`,`position`);--> statement-breakpoint
CREATE INDEX `files_owner_state` ON `files` (`ownerGuestId`,`status`);--> statement-breakpoint
CREATE TABLE `__new_idempotency_records` (
`id` text PRIMARY KEY NOT NULL,
`userId` text,
`guestId` text,
`operation` text NOT NULL,
`key` text NOT NULL,
`payloadHash` text NOT NULL,
`response` text NOT NULL,
`createdAt` integer NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "idempotency_subject" CHECK(("__new_idempotency_records"."guestId" IS NOT NULL) + ("__new_idempotency_records"."userId" IS NOT NULL) = 1)
);
--> statement-breakpoint
INSERT INTO `__new_idempotency_records`("id", "userId", "guestId", "operation", "key", "payloadHash", "response", "createdAt") SELECT "id", NULL, "guestId", "operation", "key", "payloadHash", "response", "createdAt" FROM `idempotency_records`;--> statement-breakpoint
DROP TABLE `idempotency_records`;--> statement-breakpoint
ALTER TABLE `__new_idempotency_records` RENAME TO `idempotency_records`;--> statement-breakpoint
CREATE UNIQUE INDEX `idempotency_scope` ON `idempotency_records` (`guestId`,`operation`,`key`);--> statement-breakpoint
CREATE UNIQUE INDEX `account_idempotency_scope` ON `idempotency_records` (`userId`,`operation`,`key`);--> statement-breakpoint
CREATE TABLE `__new_quota_reservations` (
`id` text PRIMARY KEY NOT NULL,
`fileId` text NOT NULL,
`userId` text,
`guestId` text,
`bytes` integer NOT NULL,
`expiresAt` integer NOT NULL,
FOREIGN KEY (`fileId`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "reservations_subject" CHECK(("__new_quota_reservations"."guestId" IS NOT NULL) + ("__new_quota_reservations"."userId" IS NOT NULL) = 1),
CONSTRAINT "reservation_nonnegative" CHECK("__new_quota_reservations"."bytes" >= 0)
);
--> statement-breakpoint
INSERT INTO `__new_quota_reservations`("id", "fileId", "userId", "guestId", "bytes", "expiresAt") SELECT "id", "fileId", NULL, "guestId", "bytes", "expiresAt" FROM `quota_reservations`;--> statement-breakpoint
DROP TABLE `quota_reservations`;--> statement-breakpoint
ALTER TABLE `__new_quota_reservations` RENAME TO `quota_reservations`;--> statement-breakpoint
CREATE UNIQUE INDEX `quota_reservations_fileId_unique` ON `quota_reservations` (`fileId`);--> statement-breakpoint
CREATE INDEX `quota_guest_expiry` ON `quota_reservations` (`guestId`,`expiresAt`);

View File

@@ -0,0 +1,17 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_deletion_operations` (
`id` text PRIMARY KEY NOT NULL,
`userId` text,
`guestId` text,
`undoUntil` integer NOT NULL,
`createdAt` integer NOT NULL,
`restoredAt` integer,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "deletion_subject" CHECK(("__new_deletion_operations"."guestId" IS NOT NULL) + ("__new_deletion_operations"."userId" IS NOT NULL) = 1)
);
--> statement-breakpoint
INSERT INTO `__new_deletion_operations`("id", "userId", "guestId", "undoUntil", "createdAt", "restoredAt") SELECT "id", "userId", "guestId", "undoUntil", "createdAt", "restoredAt" FROM `deletion_operations`;--> statement-breakpoint
DROP TABLE `deletion_operations`;--> statement-breakpoint
ALTER TABLE `__new_deletion_operations` RENAME TO `deletion_operations`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,3 @@
UPDATE transfer_requests SET status = 'cancelled' WHERE kind = 'text' AND status IN ('pending','accepted') AND EXISTS (SELECT 1 FROM transfer_batches b WHERE b.requestId = transfer_requests.id AND b.status IN ('waiting','uploading'));
--> statement-breakpoint
UPDATE transfer_batches SET status = 'failed' WHERE status IN ('waiting','uploading') AND EXISTS (SELECT 1 FROM transfer_requests r WHERE r.id = transfer_batches.requestId AND r.kind = 'text');

View File

@@ -0,0 +1,21 @@
CREATE TABLE `request_receipt_accounts` (
`requestId` text PRIMARY KEY NOT NULL,
`userId` text NOT NULL,
`sessionId` text NOT NULL,
FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`sessionId`) REFERENCES `account_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `vault_transfers` (
`requestId` text PRIMARY KEY NOT NULL,
`accessId` text NOT NULL,
`userId` text NOT NULL,
`sessionId` text NOT NULL,
FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`accessId`) REFERENCES `item_access`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`sessionId`) REFERENCES `account_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
ALTER TABLE `item_access` ADD `note` text;

View File

@@ -0,0 +1,10 @@
-- Custom SQL migration file, put your code below! --
UPDATE items SET expiresAt = min(expiresAt, CAST(strftime('%s', 'now') AS INTEGER) * 1000 + 604800000);
--> statement-breakpoint
UPDATE item_access SET favorite = 0;
--> statement-breakpoint
UPDATE transfer_requests SET status = 'cancelled' WHERE mode = 'email' AND senderUserId <> recipientUserId AND status IN ('pending', 'accepted') AND id IN (SELECT requestId FROM transfer_batches WHERE status <> 'stored');
--> statement-breakpoint
UPDATE transfer_batches SET status = 'failed' WHERE requestId IN (SELECT id FROM transfer_requests WHERE mode = 'email' AND senderUserId <> recipientUserId AND status = 'cancelled');
--> statement-breakpoint
DELETE FROM quota_reservations WHERE fileId IN (SELECT files.id FROM files INNER JOIN transfer_requests ON files.requestId = transfer_requests.id WHERE transfer_requests.mode = 'email' AND transfer_requests.senderUserId <> transfer_requests.recipientUserId AND transfer_requests.status = 'cancelled');

View File

@@ -0,0 +1 @@
ALTER TABLE `transfer_requests` ADD `note` text;

View File

@@ -0,0 +1,7 @@
UPDATE space_members SET status = 'removed' WHERE spaceId IN (SELECT id FROM connection_spaces WHERE closedAt IS NOT NULL OR expiresAt <= CAST(strftime('%s','now') AS INTEGER)*1000);
--> statement-breakpoint
UPDATE space_members AS member SET status = 'removed' WHERE status IN ('pending','approved') AND EXISTS (SELECT 1 FROM space_members newer WHERE newer.deviceId = member.deviceId AND newer.status IN ('pending','approved') AND (newer.joinedAt > member.joinedAt OR (newer.joinedAt = member.joinedAt AND newer.id > member.id)));
--> statement-breakpoint
UPDATE connection_spaces SET closedAt = CAST(strftime('%s','now') AS INTEGER)*1000 WHERE closedAt IS NULL AND NOT EXISTS (SELECT 1 FROM space_members m INNER JOIN browser_devices d ON d.id = m.deviceId WHERE m.spaceId = connection_spaces.id AND m.status = 'approved' AND d.guestId = connection_spaces.creatorGuestId);
--> statement-breakpoint
UPDATE space_members SET status = 'removed' WHERE spaceId IN (SELECT id FROM connection_spaces WHERE closedAt IS NOT NULL);

View File

@@ -0,0 +1 @@
CREATE UNIQUE INDEX `member_single_active_device` ON `space_members` (`deviceId`) WHERE "space_members"."status" IN ('pending', 'approved');

View File

@@ -0,0 +1,894 @@
{
"version": "6",
"dialect": "sqlite",
"id": "11892dda-406f-413c-ba07-9b439d0549db",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"item_access": {
"name": "item_access",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"itemId": {
"name": "itemId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"read": {
"name": "read",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"deletedAt": {
"name": "deletedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"access_subject_role": {
"name": "access_subject_role",
"columns": [
"itemId",
"guestId",
"role"
],
"isUnique": true
},
"access_guest": {
"name": "access_guest",
"columns": [
"guestId",
"deletedAt"
],
"isUnique": false
}
},
"foreignKeys": {
"item_access_itemId_items_id_fk": {
"name": "item_access_itemId_items_id_fk",
"tableFrom": "item_access",
"tableTo": "items",
"columnsFrom": [
"itemId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"item_access_guestId_guest_sessions_id_fk": {
"name": "item_access_guestId_guest_sessions_id_fk",
"tableFrom": "item_access",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"transfer_batches": {
"name": "transfer_batches",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"requestId": {
"name": "requestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"storedAt": {
"name": "storedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"transfer_batches_requestId_unique": {
"name": "transfer_batches_requestId_unique",
"columns": [
"requestId"
],
"isUnique": true
}
},
"foreignKeys": {
"transfer_batches_requestId_transfer_requests_id_fk": {
"name": "transfer_batches_requestId_transfer_requests_id_fk",
"tableFrom": "transfer_batches",
"tableTo": "transfer_requests",
"columnsFrom": [
"requestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"guest_blocks": {
"name": "guest_blocks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"targetGuestId": {
"name": "targetGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"guest_block_pair": {
"name": "guest_block_pair",
"columns": [
"guestId",
"targetGuestId"
],
"isUnique": true
}
},
"foreignKeys": {
"guest_blocks_guestId_guest_sessions_id_fk": {
"name": "guest_blocks_guestId_guest_sessions_id_fk",
"tableFrom": "guest_blocks",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"guest_blocks_targetGuestId_guest_sessions_id_fk": {
"name": "guest_blocks_targetGuestId_guest_sessions_id_fk",
"tableFrom": "guest_blocks",
"tableTo": "guest_sessions",
"columnsFrom": [
"targetGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"browser_devices": {
"name": "browser_devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"code": {
"name": "code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"discoverable": {
"name": "discoverable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"lastSeen": {
"name": "lastSeen",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"browser_devices_guestId_unique": {
"name": "browser_devices_guestId_unique",
"columns": [
"guestId"
],
"isUnique": true
},
"browser_devices_code_unique": {
"name": "browser_devices_code_unique",
"columns": [
"code"
],
"isUnique": true
}
},
"foreignKeys": {
"browser_devices_guestId_guest_sessions_id_fk": {
"name": "browser_devices_guestId_guest_sessions_id_fk",
"tableFrom": "browser_devices",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"device_name_length": {
"name": "device_name_length",
"value": "length(\"browser_devices\".\"name\") between 1 and 40"
}
}
},
"guest_sessions": {
"name": "guest_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tokenHash": {
"name": "tokenHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revokedAt": {
"name": "revokedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"guest_sessions_tokenHash_unique": {
"name": "guest_sessions_tokenHash_unique",
"columns": [
"tokenHash"
],
"isUnique": true
},
"guest_expiry": {
"name": "guest_expiry",
"columns": [
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"idempotency_records": {
"name": "idempotency_records",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"operation": {
"name": "operation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"payloadHash": {
"name": "payloadHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"response": {
"name": "response",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idempotency_scope": {
"name": "idempotency_scope",
"columns": [
"guestId",
"operation",
"key"
],
"isUnique": true
}
},
"foreignKeys": {
"idempotency_records_guestId_guest_sessions_id_fk": {
"name": "idempotency_records_guestId_guest_sessions_id_fk",
"tableFrom": "idempotency_records",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"items": {
"name": "items",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"batchId": {
"name": "batchId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"items_expiry": {
"name": "items_expiry",
"columns": [
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {
"items_batchId_transfer_batches_id_fk": {
"name": "items_batchId_transfer_batches_id_fk",
"tableFrom": "items",
"tableTo": "transfer_batches",
"columnsFrom": [
"batchId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"item_payload": {
"name": "item_payload",
"value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL)"
}
}
},
"space_members": {
"name": "space_members",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"spaceId": {
"name": "spaceId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deviceId": {
"name": "deviceId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"joinedAt": {
"name": "joinedAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"space_device": {
"name": "space_device",
"columns": [
"spaceId",
"deviceId"
],
"isUnique": true
}
},
"foreignKeys": {
"space_members_spaceId_connection_spaces_id_fk": {
"name": "space_members_spaceId_connection_spaces_id_fk",
"tableFrom": "space_members",
"tableTo": "connection_spaces",
"columnsFrom": [
"spaceId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"space_members_deviceId_browser_devices_id_fk": {
"name": "space_members_deviceId_browser_devices_id_fk",
"tableFrom": "space_members",
"tableTo": "browser_devices",
"columnsFrom": [
"deviceId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"transfer_requests": {
"name": "transfer_requests",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"mode": {
"name": "mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"senderGuestId": {
"name": "senderGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"recipientGuestId": {
"name": "recipientGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"byteSize": {
"name": "byteSize",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"acceptedAt": {
"name": "acceptedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"startBy": {
"name": "startBy",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"requests_sender_time": {
"name": "requests_sender_time",
"columns": [
"senderGuestId",
"createdAt"
],
"isUnique": false
},
"requests_recipient_time": {
"name": "requests_recipient_time",
"columns": [
"recipientGuestId",
"createdAt"
],
"isUnique": false
},
"requests_expiry": {
"name": "requests_expiry",
"columns": [
"status",
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {
"transfer_requests_senderGuestId_guest_sessions_id_fk": {
"name": "transfer_requests_senderGuestId_guest_sessions_id_fk",
"tableFrom": "transfer_requests",
"tableTo": "guest_sessions",
"columnsFrom": [
"senderGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"transfer_requests_recipientGuestId_guest_sessions_id_fk": {
"name": "transfer_requests_recipientGuestId_guest_sessions_id_fk",
"tableFrom": "transfer_requests",
"tableTo": "guest_sessions",
"columnsFrom": [
"recipientGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"request_different_guests": {
"name": "request_different_guests",
"value": "\"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\""
}
}
},
"connection_spaces": {
"name": "connection_spaces",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"creatorGuestId": {
"name": "creatorGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tokenHash": {
"name": "tokenHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"codeHash": {
"name": "codeHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approvalRequired": {
"name": "approvalRequired",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"closedAt": {
"name": "closedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"connection_spaces_tokenHash_unique": {
"name": "connection_spaces_tokenHash_unique",
"columns": [
"tokenHash"
],
"isUnique": true
},
"connection_spaces_codeHash_unique": {
"name": "connection_spaces_codeHash_unique",
"columns": [
"codeHash"
],
"isUnique": true
},
"space_expiry": {
"name": "space_expiry",
"columns": [
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {
"connection_spaces_creatorGuestId_guest_sessions_id_fk": {
"name": "connection_spaces_creatorGuestId_guest_sessions_id_fk",
"tableFrom": "connection_spaces",
"tableTo": "guest_sessions",
"columnsFrom": [
"creatorGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,901 @@
{
"version": "6",
"dialect": "sqlite",
"id": "583c8a7b-3fe7-47d9-b150-a93d5a7d8de3",
"prevId": "11892dda-406f-413c-ba07-9b439d0549db",
"tables": {
"item_access": {
"name": "item_access",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"itemId": {
"name": "itemId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"read": {
"name": "read",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"deletedAt": {
"name": "deletedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"access_subject_role": {
"name": "access_subject_role",
"columns": [
"itemId",
"guestId",
"role"
],
"isUnique": true
},
"access_guest": {
"name": "access_guest",
"columns": [
"guestId",
"deletedAt"
],
"isUnique": false
}
},
"foreignKeys": {
"item_access_itemId_items_id_fk": {
"name": "item_access_itemId_items_id_fk",
"tableFrom": "item_access",
"tableTo": "items",
"columnsFrom": [
"itemId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"item_access_guestId_guest_sessions_id_fk": {
"name": "item_access_guestId_guest_sessions_id_fk",
"tableFrom": "item_access",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"transfer_batches": {
"name": "transfer_batches",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"requestId": {
"name": "requestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"storedAt": {
"name": "storedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"transfer_batches_requestId_unique": {
"name": "transfer_batches_requestId_unique",
"columns": [
"requestId"
],
"isUnique": true
}
},
"foreignKeys": {
"transfer_batches_requestId_transfer_requests_id_fk": {
"name": "transfer_batches_requestId_transfer_requests_id_fk",
"tableFrom": "transfer_batches",
"tableTo": "transfer_requests",
"columnsFrom": [
"requestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"guest_blocks": {
"name": "guest_blocks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"targetGuestId": {
"name": "targetGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"guest_block_pair": {
"name": "guest_block_pair",
"columns": [
"guestId",
"targetGuestId"
],
"isUnique": true
}
},
"foreignKeys": {
"guest_blocks_guestId_guest_sessions_id_fk": {
"name": "guest_blocks_guestId_guest_sessions_id_fk",
"tableFrom": "guest_blocks",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"guest_blocks_targetGuestId_guest_sessions_id_fk": {
"name": "guest_blocks_targetGuestId_guest_sessions_id_fk",
"tableFrom": "guest_blocks",
"tableTo": "guest_sessions",
"columnsFrom": [
"targetGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"browser_devices": {
"name": "browser_devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"code": {
"name": "code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"discoverable": {
"name": "discoverable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"lastSeen": {
"name": "lastSeen",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"browser_devices_guestId_unique": {
"name": "browser_devices_guestId_unique",
"columns": [
"guestId"
],
"isUnique": true
},
"browser_devices_code_unique": {
"name": "browser_devices_code_unique",
"columns": [
"code"
],
"isUnique": true
}
},
"foreignKeys": {
"browser_devices_guestId_guest_sessions_id_fk": {
"name": "browser_devices_guestId_guest_sessions_id_fk",
"tableFrom": "browser_devices",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"device_name_length": {
"name": "device_name_length",
"value": "length(\"browser_devices\".\"name\") between 1 and 40"
}
}
},
"guest_sessions": {
"name": "guest_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tokenHash": {
"name": "tokenHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revokedAt": {
"name": "revokedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"guest_sessions_tokenHash_unique": {
"name": "guest_sessions_tokenHash_unique",
"columns": [
"tokenHash"
],
"isUnique": true
},
"guest_expiry": {
"name": "guest_expiry",
"columns": [
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"idempotency_records": {
"name": "idempotency_records",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"operation": {
"name": "operation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"payloadHash": {
"name": "payloadHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"response": {
"name": "response",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idempotency_scope": {
"name": "idempotency_scope",
"columns": [
"guestId",
"operation",
"key"
],
"isUnique": true
}
},
"foreignKeys": {
"idempotency_records_guestId_guest_sessions_id_fk": {
"name": "idempotency_records_guestId_guest_sessions_id_fk",
"tableFrom": "idempotency_records",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"items": {
"name": "items",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"batchId": {
"name": "batchId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"items_expiry": {
"name": "items_expiry",
"columns": [
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {
"items_batchId_transfer_batches_id_fk": {
"name": "items_batchId_transfer_batches_id_fk",
"tableFrom": "items",
"tableTo": "transfer_batches",
"columnsFrom": [
"batchId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"item_payload": {
"name": "item_payload",
"value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL)"
}
}
},
"space_members": {
"name": "space_members",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"spaceId": {
"name": "spaceId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deviceId": {
"name": "deviceId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"joinedAt": {
"name": "joinedAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"space_device": {
"name": "space_device",
"columns": [
"spaceId",
"deviceId"
],
"isUnique": true
}
},
"foreignKeys": {
"space_members_spaceId_connection_spaces_id_fk": {
"name": "space_members_spaceId_connection_spaces_id_fk",
"tableFrom": "space_members",
"tableTo": "connection_spaces",
"columnsFrom": [
"spaceId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"space_members_deviceId_browser_devices_id_fk": {
"name": "space_members_deviceId_browser_devices_id_fk",
"tableFrom": "space_members",
"tableTo": "browser_devices",
"columnsFrom": [
"deviceId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"transfer_requests": {
"name": "transfer_requests",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"mode": {
"name": "mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"senderGuestId": {
"name": "senderGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"recipientGuestId": {
"name": "recipientGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"byteSize": {
"name": "byteSize",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"acceptedAt": {
"name": "acceptedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rejectedAt": {
"name": "rejectedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"startBy": {
"name": "startBy",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"requests_sender_time": {
"name": "requests_sender_time",
"columns": [
"senderGuestId",
"createdAt"
],
"isUnique": false
},
"requests_recipient_time": {
"name": "requests_recipient_time",
"columns": [
"recipientGuestId",
"createdAt"
],
"isUnique": false
},
"requests_expiry": {
"name": "requests_expiry",
"columns": [
"status",
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {
"transfer_requests_senderGuestId_guest_sessions_id_fk": {
"name": "transfer_requests_senderGuestId_guest_sessions_id_fk",
"tableFrom": "transfer_requests",
"tableTo": "guest_sessions",
"columnsFrom": [
"senderGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"transfer_requests_recipientGuestId_guest_sessions_id_fk": {
"name": "transfer_requests_recipientGuestId_guest_sessions_id_fk",
"tableFrom": "transfer_requests",
"tableTo": "guest_sessions",
"columnsFrom": [
"recipientGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"request_different_guests": {
"name": "request_different_guests",
"value": "\"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\""
}
}
},
"connection_spaces": {
"name": "connection_spaces",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"creatorGuestId": {
"name": "creatorGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tokenHash": {
"name": "tokenHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"codeHash": {
"name": "codeHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approvalRequired": {
"name": "approvalRequired",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"closedAt": {
"name": "closedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"connection_spaces_tokenHash_unique": {
"name": "connection_spaces_tokenHash_unique",
"columns": [
"tokenHash"
],
"isUnique": true
},
"connection_spaces_codeHash_unique": {
"name": "connection_spaces_codeHash_unique",
"columns": [
"codeHash"
],
"isUnique": true
},
"space_expiry": {
"name": "space_expiry",
"columns": [
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {
"connection_spaces_creatorGuestId_guest_sessions_id_fk": {
"name": "connection_spaces_creatorGuestId_guest_sessions_id_fk",
"tableFrom": "connection_spaces",
"tableTo": "guest_sessions",
"columnsFrom": [
"creatorGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1791445972914,
"tag": "0000_aberrant_scourge",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1791447103463,
"tag": "0001_numerous_dust",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1791447352994,
"tag": "0002_orange_norrin_radd",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1791448071863,
"tag": "0003_spooky_blob",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1791449604794,
"tag": "0004_bright_wildside",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1791450987520,
"tag": "0005_tan_miracleman",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1791453590596,
"tag": "0006_file_url_only",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1791770452198,
"tag": "0007_rapid_morph",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1791770486677,
"tag": "0008_temporary-retention",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1791770606214,
"tag": "0009_vault-note",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1791771685461,
"tag": "0010_single-room",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1791771741603,
"tag": "0011_single-room-index",
"breakpoints": true
}
]
}

32
apps/server/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@hop/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"start": "node dist/main.js",
"test": "node --import tsx --test test/*.test.ts test/*.test.js",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^10.1.5",
"@hop/shared": "0.1.0",
"argon2": "^0.44.0",
"better-sqlite3": "^12.6.2",
"drizzle-orm": "^0.45.3",
"fastify": "^5.12.5",
"ipaddr.js": "^2.3.0",
"sharp": "^0.35.5",
"socket.io": "^4.8.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"drizzle-kit": "^0.31.9",
"socket.io-client": "^4.8.3",
"tsx": "^4.21.0"
}
}

667
apps/server/src/accounts.ts Normal file
View File

@@ -0,0 +1,667 @@
import { randomUUID } from 'node:crypto';
import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm';
import { parseUrls, type AccountItem, type EmailRequest } from '@hop/shared';
import type { Service, RequestRow, Guest } from './service.js';
import type { User } from './auth.js';
import * as s from './db/schema.js';
import { fail, hash } from './security.js';
export class Accounts {
constructor(readonly service: Service) {}
verified(userId: string) {
const user = this.service.auth.user(userId);
if (!this.service.auth.serialize(user).verified)
fail(403, 'VERIFICATION_REQUIRED', '이메일 인증을 완료해 주세요.');
return user;
}
notify(userId: string) {
const io = this.service.io;
if (!io) return;
for (const socket of io.sockets.sockets.values()) {
if (socket.data.accountId !== userId) continue;
const session = this.service.db
.select()
.from(s.accountSessions)
.where(eq(s.accountSessions.id, socket.data.accountSessionId))
.get();
const user = this.service.auth.user(userId);
if (
!session ||
session.revokedAt ||
session.expiresAt <= Date.now() ||
!['pending', 'active'].includes(user.status)
) {
socket.leave(`account:${userId}`);
socket.emit('account:revoked', {});
}
}
io.to(`account:${userId}`).emit('inbox:updated', {});
}
request(userId: string, id: string) {
const row = this.service.db
.select()
.from(s.requests)
.where(
and(
eq(s.requests.id, id),
eq(s.requests.mode, 'email'),
inArray(s.requests.kind, ['url', 'file']),
or(eq(s.requests.senderUserId, userId), eq(s.requests.recipientUserId, userId)),
),
)
.get();
if (!row) fail(404, 'REQUEST_NOT_FOUND', '전송 요청을 찾을 수 없습니다.');
return row!;
}
policy(senderId: string, recipientId: string) {
const recipient = this.verified(recipientId);
this.verified(senderId);
if (senderId === recipientId) return 'allow';
const rule = this.service.db
.select()
.from(s.rules)
.where(and(eq(s.rules.userId, recipientId), eq(s.rules.targetUserId, senderId)))
.get();
if (rule?.action === 'block' || recipient.receivePolicy === 'none') return 'block';
if (rule?.action === 'allow') return 'allow';
if (recipient.receivePolicy === 'allowed') return 'block';
return 'request';
}
canTransfer(row: RequestRow) {
try {
return (
row.mode === 'email' &&
row.status === 'accepted' &&
this.policy(row.senderUserId!, row.recipientUserId!) !== 'block'
);
} catch {
return false;
}
}
idem<T>(
userId: string,
operation: string,
key: string | undefined,
payload: unknown,
fn: () => T,
): T {
if (!key || !/^[A-Za-z0-9_-]{8,128}$/.test(key))
fail(400, 'IDEMPOTENCY_REQUIRED', '유효한 중복 방지 키가 필요합니다.');
const payloadHash = hash(JSON.stringify(payload));
return this.service.db.transaction(() => {
const row = this.service.db
.select()
.from(s.idempotency)
.where(
and(
eq(s.idempotency.userId, userId),
eq(s.idempotency.operation, operation),
eq(s.idempotency.key, key!),
),
)
.get();
if (row) {
if (row.payloadHash !== payloadHash)
fail(409, 'IDEMPOTENCY_CONFLICT', '같은 요청 키의 내용이 달라졌습니다.');
return JSON.parse(row.response) as T;
}
const result = fn();
this.service.db
.insert(s.idempotency)
.values({
id: randomUUID(),
userId,
operation,
key: key!,
payloadHash,
response: JSON.stringify(result),
createdAt: Date.now(),
})
.run();
return result;
});
}
create(
userId: string,
input: {
email: string;
kind: 'url' | 'file';
byteSize: number;
count: number;
files?: { name: string; size: number; type: string }[];
},
) {
const { db, cfg, limiter } = this.service;
this.verified(userId);
limiter.hit(`remote-target:${userId}`, 30, 3600000);
const recipient = db
.select()
.from(s.users)
.where(
and(
eq(s.users.email, input.email),
eq(s.users.status, 'active'),
sql`${s.users.verifiedAt} IS NOT NULL`,
),
)
.get();
if (!recipient) fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.');
let policy: string;
try {
policy = this.policy(userId, recipient!.id);
} catch {
policy = 'block';
}
if (policy === 'block') fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.');
if (
input.count > cfg.maxItems ||
(input.kind !== 'file' && input.byteSize > cfg.maxUrlBytes) ||
input.files?.some((f) => f.size > cfg.maxFileBytes)
)
fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.');
if (policy === 'request') {
limiter.hit(`remote-new:${userId}`, 20, 86400000);
const pending = db
.select()
.from(s.requests)
.where(
and(
eq(s.requests.recipientUserId, recipient!.id),
eq(s.requests.status, 'pending'),
gt(s.requests.expiresAt, Date.now()),
),
)
.all();
if (pending.length >= 50) fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.');
}
const now = Date.now(),
id = randomUUID();
db.insert(s.requests)
.values({
id,
mode: 'email',
senderUserId: userId,
recipientUserId: recipient!.id,
kind: input.kind,
byteSize: input.byteSize,
count: input.count,
manifest: input.files ? JSON.stringify(input.files) : null,
status: policy === 'allow' ? 'accepted' : 'pending',
createdAt: now,
expiresAt: now + cfg.emailRequestDays * 86400000,
acceptedAt: policy === 'allow' ? now : null,
startBy: policy === 'allow' ? now + cfg.uploadStartHours * 3600000 : null,
})
.run();
db.insert(s.batches).values({ id: randomUUID(), requestId: id, status: 'waiting' }).run();
if (input.files) {
this.service.storage.createManifest({ id: userId, kind: 'user' }, id, input.files);
if (policy === 'allow') this.service.storage.reserve({ id: userId, kind: 'user' }, id);
}
return { requestId: id, requiresAcceptance: policy !== 'allow' };
}
accept(userId: string, id: string, allowSender = false) {
this.verified(userId);
const row = this.request(userId, id);
if (row.recipientUserId !== userId) fail(403, 'FORBIDDEN', '수신 계정만 수락할 수 있습니다.');
if (
row.status !== 'pending' ||
row.expiresAt <= Date.now() ||
this.policy(row.senderUserId!, userId) === 'block'
)
fail(409, 'REQUEST_STATE', '수락할 수 없는 요청입니다.');
if (row.kind === 'file')
this.service.storage.reserve({ id: row.senderUserId!, kind: 'user' }, id);
if (allowSender) this.rule(userId, row.senderUserId!, 'allow');
const now = Date.now();
this.service.db
.update(s.requests)
.set({
status: 'accepted',
acceptedAt: now,
startBy: now + this.service.cfg.uploadStartHours * 3600000,
})
.where(eq(s.requests.id, id))
.run();
return { requestId: id };
}
reject(userId: string, id: string) {
this.verified(userId);
const row = this.request(userId, id);
if (row.recipientUserId !== userId || row.status !== 'pending')
fail(409, 'REQUEST_STATE', '거절할 수 없는 요청입니다.');
this.service.db
.update(s.requests)
.set({ status: 'rejected', rejectedAt: Date.now() })
.where(eq(s.requests.id, id))
.run();
}
cancel(userId: string, id: string) {
const row = this.request(userId, id),
batch = this.service.db.select().from(s.batches).where(eq(s.batches.requestId, id)).get()!;
if (
row.senderUserId !== userId ||
!['pending', 'accepted'].includes(row.status) ||
batch.status === 'stored'
)
fail(409, 'REQUEST_STATE', '취소할 수 없는 요청입니다.');
this.service.storage.release(id);
this.service.db
.update(s.requests)
.set({ status: 'cancelled' })
.where(eq(s.requests.id, id))
.run();
this.service.db
.update(s.batches)
.set({ status: 'failed' })
.where(eq(s.batches.id, batch.id))
.run();
}
complete(userId: string, id: string, input: { urls: string[] }) {
const row = this.request(userId, id);
if (
row.senderUserId !== userId ||
!this.canTransfer(row) ||
!row.startBy ||
row.startBy <= Date.now()
)
fail(409, 'REQUEST_STATE', '수락된 유효한 요청이 필요합니다.');
if (row.kind === 'file')
fail(400, 'FILE_UPLOAD_REQUIRED', '파일은 업로드 API를 사용해 주세요.');
const batch = this.service.db
.select()
.from(s.batches)
.where(eq(s.batches.requestId, id))
.get()!;
if (batch.status !== 'waiting') fail(409, 'TRANSFER_STATE', '이미 처리된 전송입니다.');
if (row.kind !== 'url')
fail(400, 'TYPE_UNSUPPORTED', '파일과 HTTP/HTTPS 링크만 보낼 수 있습니다.');
const parsed = parseUrls(input.urls.join('\n'));
if (parsed.invalid.length || parsed.urls.length !== input.urls.length)
fail(400, 'INVALID_URL', 'HTTP 또는 HTTPS 링크만 보낼 수 있습니다.');
const values = parsed.urls;
if (Buffer.byteLength(values.join('\n')) !== row.byteSize || values.length !== row.count)
fail(409, 'METADATA_MISMATCH', '내용 크기나 개수가 바뀌었습니다. 새 요청을 보내 주세요.');
const now = Date.now();
for (const value of values) {
const itemId = randomUUID();
this.service.db
.insert(s.items)
.values({
id: itemId,
batchId: batch.id,
kind: row.kind,
url: value,
createdAt: now,
expiresAt: now + this.service.cfg.urlRetentionDays * 86400000,
})
.run();
this.createAccess(row, itemId);
}
this.service.db
.update(s.batches)
.set({ status: 'stored', storedAt: now })
.where(eq(s.batches.id, batch.id))
.run();
return { requestId: id, batchId: batch.id };
}
createAccess(row: RequestRow, itemId: string) {
if (row.mode === 'local') {
for (const [guestId, role] of [
[row.senderGuestId!, 'sender'],
[row.recipientGuestId!, 'recipient'],
] as const)
this.service.db
.insert(s.access)
.values({ id: randomUUID(), itemId, guestId, role, read: false })
.run();
this.service.vault.receipt(row, itemId);
} else if (row.senderUserId === row.recipientUserId)
this.service.db
.insert(s.access)
.values({
id: randomUUID(),
itemId,
userId: row.senderUserId!,
role: 'self',
read: false,
note: row.note,
})
.run();
else
for (const [userId, role] of [
[row.senderUserId!, 'sender'],
[row.recipientUserId!, 'recipient'],
] as const)
this.service.db
.insert(s.access)
.values({ id: randomUUID(), itemId, userId, role, read: false })
.run();
}
listRequests(userId: string): EmailRequest[] {
this.verified(userId);
return this.service.db
.select({ request: s.requests, batch: s.batches })
.from(s.requests)
.innerJoin(s.batches, eq(s.requests.id, s.batches.requestId))
.where(
and(
eq(s.requests.mode, 'email'),
gt(s.requests.createdAt, Date.now() - 7 * 86400000),
inArray(s.requests.kind, ['url', 'file']),
or(eq(s.requests.senderUserId, userId), eq(s.requests.recipientUserId, userId)),
),
)
.orderBy(desc(s.requests.createdAt), desc(s.requests.id))
.limit(100)
.all()
.map(({ request: r, batch: b }) => ({
id: r.id,
batchId: b.id,
kind: r.kind as 'url' | 'file',
byteSize: r.byteSize,
count: r.count,
status: r.status,
transferStatus: b.status,
createdAt: r.createdAt,
expiresAt: r.expiresAt,
startBy: r.startBy,
direction: r.senderUserId === userId ? 'sent' : 'received',
sender: this.service.auth.serialize(this.service.auth.user(r.senderUserId!)),
recipient: this.service.auth.serialize(this.service.auth.user(r.recipientUserId!)),
files:
r.kind === 'file'
? this.service.storage.fileRows(r.id).map((f) => ({
id: f.id,
name: f.name,
size: f.size,
type: f.type,
status: f.status,
}))
: undefined,
}));
}
access(userId: string, id: string) {
this.verified(userId);
const row = this.service.db
.select({ access: s.access, item: s.items })
.from(s.access)
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
.where(
and(
eq(s.access.id, id),
eq(s.access.userId, userId),
inArray(s.items.kind, ['url', 'file']),
isNull(s.access.deletedAt),
gt(s.items.expiresAt, Date.now()),
),
)
.get();
if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.');
return row!;
}
read(userId: string, id: string, value: boolean) {
const row = this.access(userId, id);
this.service.db
.update(s.access)
.set({ read: value })
.where(and(eq(s.access.itemId, row.item.id), eq(s.access.role, row.access.role)))
.run();
for (const access of this.service.db
.select()
.from(s.access)
.where(eq(s.access.itemId, row.item.id))
.all()) {
if (access.userId) this.notify(access.userId);
if (access.guestId) this.service.presence.emit(access.guestId, 'access:updated', {});
}
}
list(
userId: string,
view: 'inbox' | 'vault' | 'sent' | 'favorites' | 'contents',
query: { cursor?: string; limit: number; search?: string; kind?: string; read?: boolean },
) {
this.verified(userId);
let after: { createdAt: number; id: string } | undefined;
if (query.cursor) {
try {
after = JSON.parse(Buffer.from(query.cursor, 'base64url').toString());
if (!after || !Number.isSafeInteger(after.createdAt) || typeof after.id !== 'string')
throw Error();
} catch {
fail(400, 'INVALID_CURSOR', '목록 커서가 올바르지 않습니다.');
}
}
const rows = this.service.db
.select({ access: s.access, item: s.items, request: s.requests })
.from(s.access)
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
.innerJoin(s.batches, eq(s.items.batchId, s.batches.id))
.innerJoin(s.requests, eq(s.batches.requestId, s.requests.id))
.where(
and(
eq(s.access.userId, userId),
isNull(s.access.deletedAt),
gt(s.items.expiresAt, Date.now()),
view === 'contents'
? inArray(s.access.role, ['self', 'recipient'])
: view === 'favorites'
? eq(s.access.favorite, true)
: eq(
s.access.role,
view === 'inbox' ? 'recipient' : view === 'vault' ? 'self' : 'sender',
),
inArray(s.items.kind, ['url', 'file']),
query.kind ? eq(s.items.kind, query.kind as any) : undefined,
query.read !== undefined ? eq(s.access.read, query.read) : undefined,
query.search
? sql`(instr(lower(coalesce(${s.access.note},'')),lower(${query.search})) > 0 OR instr(lower(coalesce(${s.items.url},'')),lower(${query.search})) > 0 OR EXISTS (SELECT 1 FROM files f WHERE f.id = ${s.items.fileId} AND instr(lower(f.name),lower(${query.search})) > 0))`
: undefined,
after
? or(
lt(s.items.createdAt, after.createdAt),
and(eq(s.items.createdAt, after.createdAt), lt(s.access.id, after.id)),
)
: undefined,
),
)
.orderBy(desc(s.items.createdAt), desc(s.access.id))
.limit(query.limit + 1)
.all();
const result: AccountItem[] = rows.slice(0, query.limit).map((r) => {
const peerId =
r.access.role === 'sender' ? r.request.recipientUserId : r.request.senderUserId;
const peer = peerId ? this.service.auth.user(peerId) : null;
const file = r.item.fileId
? this.service.db.select().from(s.files).where(eq(s.files.id, r.item.fileId)).get()
: null;
const recipient = this.service.db
.select()
.from(s.access)
.where(and(eq(s.access.itemId, r.item.id), eq(s.access.role, 'recipient')))
.get();
return {
accessId: r.access.id,
id: r.item.id,
batchId: r.item.batchId,
kind: r.item.kind as 'url' | 'file',
url: r.item.url,
role: r.access.role,
read: r.access.read,
favorite: r.access.favorite,
note: r.access.note,
file: file ? { id: file.id, name: file.name, size: file.size, type: file.type } : undefined,
peerName:
peer?.displayName ??
(r.request.senderGuestId
? this.service.device(r.request.senderGuestId).name
: '익명 기기'),
peerCode: '',
senderEmail: peer?.email ?? undefined,
senderName: peer?.displayName,
createdAt: r.item.createdAt,
expiresAt: r.item.expiresAt,
recipientRead: recipient?.read ?? false,
};
});
const last = result.at(-1);
return {
items: result,
nextCursor:
rows.length > query.limit && last
? Buffer.from(JSON.stringify({ createdAt: last.createdAt, id: last.accessId })).toString(
'base64url',
)
: 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
.select({ file: s.files })
.from(s.access)
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
.innerJoin(s.files, eq(s.items.fileId, s.files.id))
.where(
and(
eq(s.access.userId, userId),
or(eq(s.access.favorite, true), eq(s.access.role, 'self')),
isNull(s.access.deletedAt),
gt(s.items.expiresAt, Date.now()),
),
)
.all();
for (const { file } of preserved) accounted.set(file.id, file.size);
return [...accounted.values()].reduce((a, b) => a + b, 0);
}
ensureQuota(userId: string, bytes: number) {
if (this.used(userId) + bytes > this.service.cfg.userQuota)
fail(413, 'ACCOUNT_QUOTA', '계정 저장 용량을 초과했습니다.');
}
favorite(userId: string, id: string, value: boolean) {
this.service.db.transaction(() => {
const row = this.access(userId, id);
if (value && !row.access.favorite && row.item.fileId) {
const file = this.service.db
.select()
.from(s.files)
.where(eq(s.files.id, row.item.fileId))
.get()!;
if (file.ownerUserId !== userId && row.access.role !== 'self')
this.ensureQuota(userId, file.size);
}
this.service.db.update(s.access).set({ favorite: value }).where(eq(s.access.id, id)).run();
});
this.notify(userId);
}
move(g: Guest, userId: string, id: string) {
this.verified(userId);
return this.service.db.transaction(() => {
const row = this.service.accessDetail(g, id);
if (row.item.fileId) {
const file = this.service.db
.select()
.from(s.files)
.where(eq(s.files.id, row.item.fileId))
.get()!;
this.ensureQuota(userId, file.size);
if (file.ownerGuestId === g.id)
this.service.db
.update(s.files)
.set({ ownerGuestId: null, ownerUserId: userId })
.where(eq(s.files.id, file.id))
.run();
}
const existing = this.service.db
.select()
.from(s.access)
.where(
and(
eq(s.access.itemId, row.item.id),
eq(s.access.userId, userId),
eq(s.access.role, 'self'),
),
)
.get();
if (existing)
this.service.db
.update(s.access)
.set({ deletedAt: null })
.where(eq(s.access.id, existing.id))
.run();
else
this.service.db
.insert(s.access)
.values({
id: randomUUID(),
itemId: row.item.id,
userId,
role: 'self',
read: row.access.read,
})
.run();
this.service.db
.update(s.access)
.set({ deletedAt: Date.now() })
.where(eq(s.access.id, row.access.id))
.run();
return { ok: true };
});
}
rule(userId: string, target: string, action: 'allow' | 'block') {
this.verified(userId);
if (target === userId)
fail(400, 'RULE_INVALID', '자기 계정에는 수신 규칙을 적용하지 않습니다.');
const candidate = this.service.db
.select()
.from(s.requests)
.where(and(eq(s.requests.senderUserId, target), eq(s.requests.recipientUserId, userId)))
.get();
const existing = this.service.db
.select()
.from(s.rules)
.where(and(eq(s.rules.userId, userId), eq(s.rules.targetUserId, target)))
.get();
if (!candidate && !existing)
fail(404, 'SENDER_NOT_FOUND', '수신 관계가 있는 발신자만 관리할 수 있습니다.');
this.service.db
.insert(s.rules)
.values({ id: randomUUID(), userId, targetUserId: target, action })
.onConflictDoUpdate({ target: [s.rules.userId, s.rules.targetUserId], set: { action } })
.run();
if (action === 'block') {
const affected = this.service.db
.select({ request: s.requests, batch: s.batches })
.from(s.requests)
.innerJoin(s.batches, eq(s.requests.id, s.batches.requestId))
.where(
and(
eq(s.requests.senderUserId, target),
eq(s.requests.recipientUserId, userId),
inArray(s.requests.status, ['pending', 'accepted']),
inArray(s.batches.status, ['waiting', 'uploading']),
),
)
.all();
for (const { request } of affected) {
this.service.storage.release(request.id);
this.service.db
.update(s.requests)
.set({
status: request.status === 'pending' ? 'rejected' : 'cancelled',
rejectedAt: Date.now(),
})
.where(eq(s.requests.id, request.id))
.run();
}
this.notify(target);
}
this.notify(userId);
}
}

828
apps/server/src/app.ts Normal file
View File

@@ -0,0 +1,828 @@
import Fastify, { LogController, type FastifyRequest } from 'fastify';
class PrivateLogs extends LogController {
constructor() {
super({ disableRequestLogging: true });
}
}
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';
import { existsSync } from 'node:fs';
import { z } from 'zod';
import {
devicePatch,
requestInput,
spaceInput,
joinInput,
completeInput,
emailRequestInput,
} from '@hop/shared';
import { createResendMailer } from './mail.js';
import { Auth, normalizeEmail, type Mailer } from './auth.js';
import { Accounts } from './accounts.js';
import * as tables from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
import type { StorageSubject } from './storage.js';
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';
export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) {
const app = Fastify({
logger: cfg.production
? {
level: 'info',
redact: ['req.headers.cookie', 'req.headers.authorization', 'req.url'],
serializers: { req: (r) => ({ method: r.method }) },
}
: false,
logController: new PrivateLogs(),
trustProxy: false,
bodyLimit: cfg.maxUrlBytes * 6 + 16384,
});
await app.register(cookie);
await app.register(multipart, {
limits: { fileSize: cfg.maxFileBytes + 1, files: 1, fields: 0, parts: 1 },
});
const { db, sqlite } = openDatabase(cfg.databasePath),
service = new Service(db, cfg);
let devOrigin: string | undefined;
const allowed = (origin: string | undefined) =>
Boolean(
origin &&
(origin === new URL(cfg.appUrl).origin || (!cfg.production && origin === devOrigin)),
);
const ip = (req: FastifyRequest) =>
clientIp(req.raw.socket.remoteAddress, req.headers, cfg.trustedProxies);
const io = new Server(app.server, {
serveClient: false,
maxHttpBufferSize: 16384,
allowRequest: (req, callback) => {
// Browsers omit Origin for same-origin polling GETs. Fetch Metadata is browser-owned;
// cookie authentication and the session-bound CSRF token are also required by io.use.
const sameOriginFetch =
!req.headers.origin && req.headers['sec-fetch-site'] === 'same-origin';
callback(null, allowed(req.headers.origin) || sameOriginFetch);
},
});
const presence = new Presence(io, cfg, () => service.refresh());
service.presence = presence;
service.io = io;
service.auth = new Auth(service, options.mailer ?? createResendMailer(cfg));
service.accounts = new Accounts(service);
io.use((socket, next) => {
try {
const token = app.parseCookie(socket.request.headers.cookie ?? '').hop_guest;
const guest = service.guest(token);
if (!guest || socket.handshake.auth.csrfToken !== service.csrf(token!))
return next(new Error('SESSION_EXPIRED'));
socket.data.guest = guest;
const account = service.auth.session(
app.parseCookie(socket.request.headers.cookie ?? '').hop_account,
);
if (account) {
socket.data.accountId = account.user.id;
socket.data.accountSessionId = account.session.id;
}
socket.data.ip = clientIp(
socket.request.socket.remoteAddress,
socket.request.headers,
cfg.trustedProxies,
);
next();
} catch {
next(new Error('SESSION_EXPIRED'));
}
});
io.on('connection', (socket) => {
presence.connect((socket.data.guest as Guest).id, socket, socket.data.ip);
if (socket.data.accountId) {
socket.join(`account:${socket.data.accountId}`);
socket.join(`account-session:${socket.data.accountSessionId}`);
}
});
const auth = (req: FastifyRequest) => {
const guest = service.guest(req.cookies.hop_guest);
if (!guest) fail(401, 'SESSION_EXPIRED', '기기 세션이 만료되었습니다. 새로 접속해 주세요.');
return guest!;
};
const account = (req: FastifyRequest, verified = false) =>
service.auth.require(req.cookies.hop_account, verified);
const requestSubject = (req: FastifyRequest, id: string): StorageSubject => {
const row = db.select().from(tables.requests).where(eq(tables.requests.id, id)).get();
const user = service.auth.session(req.cookies.hop_account)?.user;
if (
row?.mode === 'email' &&
user &&
(row.senderUserId === user.id || row.recipientUserId === user.id)
) {
account(req, true);
return { id: user.id, kind: 'user', sessionId: account(req, true).session.id };
}
return auth(req);
};
const accessSubject = (req: FastifyRequest, id: string): StorageSubject => {
const row = db.select().from(tables.access).where(eq(tables.access.id, id)).get(),
user = service.auth.session(req.cookies.hop_account)?.user;
if (user && row?.userId === user.id) {
account(req, true);
return { id: user.id, kind: 'user', sessionId: account(req, true).session.id };
}
return auth(req);
};
const readAccess = (subject: StorageSubject, id: string, value: boolean) => {
if (subject.kind === 'user') service.accounts.read(subject.id, id, value);
else service.read(subject as Guest, id, value);
};
const csrf = (req: FastifyRequest) => {
const token = req.cookies.hop_guest;
const actual = req.headers['x-csrf-token'];
if (!token || typeof actual !== 'string')
fail(403, 'CSRF_INVALID', '요청을 확인할 수 없습니다. 새로고침해 주세요.');
const expected = Buffer.from(service.csrf(token!));
const given = Buffer.from(actual as string);
if (given.length !== expected.length || !timingSafeEqual(given, expected))
fail(403, 'CSRF_INVALID', '요청을 확인할 수 없습니다.');
};
const key = (req: FastifyRequest) =>
typeof req.headers['idempotency-key'] === 'string' ? req.headers['idempotency-key'] : undefined;
const id = (req: FastifyRequest) => z.object({ id: z.uuid() }).parse(req.params).id;
app.addHook('onRequest', async (req, reply) => {
reply
.header('X-Content-Type-Options', 'nosniff')
.header('Referrer-Policy', 'no-referrer')
.header('X-Frame-Options', 'DENY')
.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), bluetooth=()');
reply.header(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'",
);
if (req.url.startsWith('/api/')) {
reply.header('Cache-Control', 'no-store');
service.limiter.hit(`api-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, 240);
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
if (!allowed(req.headers.origin))
fail(403, 'ORIGIN_INVALID', '허용되지 않은 접속 경로입니다.');
if (req.url.split('?')[0] !== '/api/guest/session') {
auth(req);
csrf(req);
}
}
}
});
app.setErrorHandler((err, req, reply) => {
let status = 500,
code = 'INTERNAL_ERROR',
message = '처리 중 오류가 발생했습니다.';
if (err instanceof ApiError) {
status = err.status;
code = err.code;
message = err.message;
} else if (err instanceof z.ZodError) {
status = 400;
code = 'VALIDATION_ERROR';
message = '입력 내용을 확인해 주세요.';
} else if (
err &&
typeof err === 'object' &&
'statusCode' in err &&
typeof err.statusCode === 'number' &&
err.statusCode < 500
) {
status = err.statusCode;
code = 'INVALID_REQUEST';
message = '요청 형식이나 크기를 확인해 주세요.';
}
reply.status(status).send({ code, message, requestId: req.id });
});
const setCookie = (reply: any, token: string, expiresAt: number) =>
reply.setCookie('hop_guest', token, {
httpOnly: true,
secure: cfg.production,
sameSite: 'lax',
path: '/',
expires: new Date(expiresAt),
});
app.get('/api/health', async () => ({ status: 'ok', phase: 1 }));
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,
guest = service.guest(token);
if (!guest) {
const created = service.createGuest(req.headers['user-agent'] ?? '');
token = created.token;
guest = created.guest;
}
setCookie(reply, token!, guest.expiresAt);
return {
device: service.device(guest.id),
csrfToken: service.csrf(token!),
expiresAt: guest.expiresAt,
};
});
app.get('/api/device/snapshot', async (req) => {
const g = auth(req);
return db.transaction(() => {
const r = service.listRequests(g),
list = service.listItems(g);
return {
device: service.device(g.id),
devices: service.peers(g),
sameNetworkDiscoveryEnabled: cfg.discoveryEnabled,
requests: r,
items: list.items,
nextCursor: list.nextCursor,
spaces: service.listSpaces(g),
};
});
});
app.get('/api/device/me', async (req) => ({
device: service.device(auth(req).id),
sameNetworkDiscoveryEnabled: cfg.discoveryEnabled,
}));
app.patch('/api/device/me', async (req) => {
const guest = auth(req);
service.patchDevice(guest, devicePatch.parse(req.body));
return { device: service.device(guest.id) };
});
app.post('/api/device/reset', async (req, reply) => {
const guest = auth(req);
service.reset(guest);
const created = service.createGuest(req.headers['user-agent'] ?? '');
setCookie(reply, created.token, created.guest.expiresAt);
return {
device: service.device(created.guest.id),
csrfToken: service.csrf(created.token),
expiresAt: created.guest.expiresAt,
};
});
app.get('/api/discovery/devices', async (req) => ({
devices: service.peers(auth(req)),
sameNetworkDiscoveryEnabled: cfg.discoveryEnabled,
}));
app.get('/api/spaces', async (req) => ({ spaces: service.listSpaces(auth(req)) }));
app.post('/api/spaces', async (req) =>
service.createSpace(auth(req), spaceInput.parse(req.body).approvalRequired),
);
app.post('/api/spaces/join', async (req) =>
service.joinSpace(auth(req), joinInput.parse(req.body), ip(req) ?? 'unknown'),
);
app.post('/api/spaces/:id/members/:memberId/approve', async (req) => {
const p = z.object({ id: z.uuid(), memberId: z.uuid() }).parse(req.params);
service.approve(auth(req), p.id, p.memberId);
return { ok: true };
});
app.delete('/api/spaces/:id/members/:memberId', async (req) => {
const p = z.object({ id: z.uuid(), memberId: z.uuid() }).parse(req.params);
service.remove(auth(req), p.id, p.memberId);
return { ok: true };
});
app.delete('/api/spaces/:id', async (req) => {
service.closeSpace(auth(req), id(req));
return { ok: true };
});
app.get('/api/transfer-requests', async (req) => {
const mode = (req.query as { mode?: string }).mode;
return {
requests:
mode === 'email'
? service.accounts.listRequests(account(req, true).user.id)
: service.listRequests(auth(req)),
};
});
app.post('/api/transfer-requests', async (req) => {
if ((req.body as { mode?: string })?.mode === 'email') {
const user = account(req, true).user,
input = emailRequestInput.parse(req.body);
input.email = normalizeEmail(input.email);
if (input.email !== user.email)
fail(410, 'EMAIL_TRANSFER_REMOVED', '이메일 전송 대신 내 임시 보관함을 이용해 주세요.');
const result = service.accounts.idem(user.id, 'request-create', key(req), input, () =>
service.accounts.create(user.id, input),
);
service.notifyTransfer(result.requestId);
return result;
}
const g = auth(req),
input = requestInput.parse(req.body);
const result = service.idem(g, 'request-create', key(req), input, () =>
service.createRequest(g, input, ip(req) ?? 'unknown'),
);
service.notifyTransfer(result.requestId);
return result;
});
app.post('/api/transfer-requests/:id/accept', async (req) => {
const requestId = id(req),
subject = requestSubject(req, requestId);
let result;
if (subject.kind === 'user') {
const input = z
.object({ allowSender: z.boolean().default(false) })
.strict()
.parse(req.body);
result = service.accounts.idem(subject.id, `accept:${requestId}`, key(req), input, () =>
service.accounts.accept(subject.id, requestId, input.allowSender),
);
} else {
service.request(subject as Guest, requestId);
result = service.idem(subject as Guest, `accept:${requestId}`, key(req), {}, () =>
(() => {
const result = service.accept(subject as Guest, requestId);
service.vault.bindReceipt(requestId, service.auth.session(req.cookies.hop_account));
service.vault.deliver(requestId);
return result;
})(),
);
}
service.notifyTransfer(requestId);
return result;
});
app.post('/api/transfer-requests/:id/reject', async (req) => {
const requestId = id(req),
subject = requestSubject(req, requestId);
if (subject.kind === 'user') service.accounts.reject(subject.id, requestId);
else service.reject(subject as Guest, requestId);
service.notifyTransfer(requestId);
return { ok: true };
});
app.post('/api/transfer-requests/:id/cancel', async (req) => {
const requestId = id(req),
subject = requestSubject(req, requestId);
if (subject.kind === 'user') service.accounts.cancel(subject.id, requestId);
else service.cancel(subject as Guest, requestId);
service.notifyTransfer(requestId);
return { ok: true };
});
app.post('/api/transfer-requests/:id/block', async (req) => {
const requestId = id(req),
subject = requestSubject(req, requestId);
if (subject.kind === 'user') {
const row = service.accounts.request(subject.id, requestId);
if (row.recipientUserId !== subject.id)
fail(403, 'FORBIDDEN', '수신 계정만 차단할 수 있습니다.');
service.accounts.rule(subject.id, row.senderUserId!, 'block');
} else service.block(subject as Guest, requestId);
return { ok: true };
});
app.post('/api/transfers/:id/complete', async (req) => {
const requestId = id(req),
subject = requestSubject(req, requestId),
input = completeInput.parse(req.body);
if (service.vault.source(requestId))
fail(409, 'VAULT_TRANSFER_MANAGED', '보관함 전송은 서버에서 처리합니다.');
let result;
if (subject.kind === 'user')
result = service.accounts.idem(subject.id, `complete:${requestId}`, key(req), input, () =>
service.accounts.complete(subject.id, requestId, input),
);
else {
service.request(subject as Guest, requestId);
result = service.idem(subject as Guest, `complete:${requestId}`, key(req), input, () =>
service.complete(subject as Guest, requestId, input),
);
}
service.notifyTransfer(requestId);
return result;
});
app.get('/api/device/items', async (req) => {
const q = z
.object({
cursor: z.string().max(512).optional(),
limit: z.coerce.number().int().min(1).max(100).default(30),
})
.parse(req.query);
return service.listItems(auth(req), q.cursor, q.limit);
});
app.get('/api/access/:id', async (req) => ({
item:
accessSubject(req, id(req)).kind === 'user'
? service.accounts.access(account(req, true).user.id, id(req)).item
: service.accessDetail(auth(req), id(req)).item,
}));
app.patch('/api/access/:id/read', async (req) => {
const body = z.object({ read: z.boolean() }).strict().parse(req.body);
readAccess(accessSubject(req, id(req)), id(req), body.read);
return { ok: true };
});
app.post('/api/uploads', async (req) => {
const input = z
.object({
requestId: z.uuid(),
fileIndex: z.coerce
.number()
.int()
.min(0)
.max(cfg.maxItems - 1),
})
.parse(req.query);
const uploadKey = z
.string()
.regex(/^[A-Za-z0-9_-]{8,128}$/)
.parse(key(req));
if (service.vault.source(input.requestId))
fail(409, 'VAULT_TRANSFER_MANAGED', '이미 저장된 파일을 전달합니다.');
return service.storage.upload(
requestSubject(req, input.requestId),
input.requestId,
input.fileIndex,
req,
uploadKey,
);
});
app.delete('/api/uploads/:id', async (req) => {
service.storage.cancel(
(() => {
const file = db
.select()
.from(tables.files)
.where(eq(tables.files.uploadId, id(req)))
.get();
const user = service.auth.session(req.cookies.hop_account)?.user;
return user && file?.ownerUserId === user.id
? { id: user.id, kind: 'user' as const }
: auth(req);
})(),
id(req),
);
return { ok: true };
});
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 fallback =
file.name.replace(/[^a-zA-Z0-9._ -]/g, '_').replace(/^[. ]+/, '') || 'download';
const encoded = encodeURIComponent(file.name).replace(
/['()*]/g,
(c) => '%' + c.charCodeAt(0).toString(16).toUpperCase(),
);
reply
.header(
'Content-Disposition',
`attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`,
)
.header('Content-Length', file.size)
.type('application/octet-stream');
readAccess(g, accessId, true);
return reply.send(createReadStream(path));
});
app.get('/api/access/:id/preview', async (req, reply) =>
reply
.type('image/webp')
.send(await service.storage.preview(accessSubject(req, id(req)), id(req))),
);
app.post('/api/access/delete-batch', async (req) => {
const body = z
.object({ ids: z.array(z.uuid()).min(1).max(100) })
.strict()
.parse(req.body),
g = accessSubject(req, body.ids[0]!);
const result = service.storage.deleteAccess(g, body.ids);
if (g.kind === 'user') service.accounts.notify(g.id);
else presence.emit(g.id, 'access:updated', {});
return result;
});
app.post('/api/deletion-operations/:id/undo', async (req) => {
const operation = db
.select()
.from(tables.deletions)
.where(eq(tables.deletions.id, id(req)))
.get();
const user = service.auth.session(req.cookies.hop_account)?.user;
const g: StorageSubject =
user && operation?.userId === user.id ? { id: user.id, kind: 'user' } : auth(req);
const result = service.storage.undo(g, id(req));
if (g.kind === 'user') service.accounts.notify(g.id);
else presence.emit(g.id, 'access:updated', {});
return result;
});
const credentials = z
.object({ email: z.string().max(254), password: z.string().min(1).max(1024) })
.strict();
const registration = credentials
.extend({
passwordConfirmation: z.string().max(1024),
displayName: z
.string()
.trim()
.min(1)
.max(40)
.refine((v) => !/[\x00-\x1f\x7f<>]/.test(v)),
setupToken: z.string().min(32).max(256).optional(),
})
.refine((v) => v.password === v.passwordConfirmation);
const tokenInput = z.object({ token: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).strict();
const clearAccount = (reply: any) =>
reply.clearCookie('hop_account', {
path: '/',
httpOnly: true,
secure: cfg.production,
sameSite: 'lax',
});
app.get('/api/auth/me', async (req) => {
const current = service.auth.session(req.cookies.hop_account);
return {
account: current ? service.auth.serialize(current.user) : null,
registrationOpen: cfg.registrationMode === 'open',
passwordMinLength: cfg.production ? 12 : 1,
};
});
app.post('/api/auth/register', async (req) => {
const input = registration.parse(req.body);
if (input.setupToken) fail(400, 'INVALID_REQUEST', '초기화는 별도 경로를 사용해 주세요.');
return service.auth.register(
input.email,
input.password,
input.displayName,
ip(req) ?? 'unknown',
);
});
app.post('/api/auth/setup', async (req) => {
const input = registration.parse(req.body);
if (!input.setupToken) fail(403, 'SETUP_DISABLED', '초기화 토큰이 필요합니다.');
return service.auth.register(
input.email,
input.password,
input.displayName,
ip(req) ?? 'unknown',
input.setupToken,
);
});
app.post('/api/auth/verify-email', async (req) =>
service.auth.verify(tokenInput.parse(req.body).token),
);
app.post('/api/auth/verify-email-change', async (req) =>
service.auth.verifyEmailChange(tokenInput.parse(req.body).token),
);
app.post('/api/auth/resend-verification', async (req) =>
service.auth.resend(
z
.object({ email: z.string().max(254) })
.strict()
.parse(req.body).email,
'verify',
ip(req) ?? 'unknown',
),
);
app.post('/api/auth/login', async (req, reply) => {
const input = credentials.parse(req.body);
const result = await service.auth.login(
input.email,
input.password,
ip(req) ?? 'unknown',
req.cookies.hop_account,
);
reply.setCookie('hop_account', result.token, {
httpOnly: true,
secure: cfg.production,
sameSite: 'lax',
path: '/',
expires: new Date(result.expiresAt),
});
return { account: result.account };
});
app.post('/api/auth/logout', async (req, reply) => {
service.auth.logout(req.cookies.hop_account);
clearAccount(reply);
return { ok: true };
});
app.post('/api/auth/forgot-password', async (req) =>
service.auth.resend(
z
.object({ email: z.string().max(254) })
.strict()
.parse(req.body).email,
'reset',
ip(req) ?? 'unknown',
),
);
app.post('/api/auth/reset-password', async (req) => {
const input = tokenInput
.extend({ password: z.string().max(1024), passwordConfirmation: z.string().max(1024) })
.refine((v) => v.password === v.passwordConfirmation)
.parse(req.body);
return service.auth.resetPassword(input.token, input.password);
});
app.post('/api/auth/change-password', async (req, reply) => {
const input = z
.object({
currentPassword: z.string().max(1024),
password: z.string().max(1024),
passwordConfirmation: z.string().max(1024),
})
.strict()
.refine((v) => v.password === v.passwordConfirmation)
.parse(req.body);
await service.auth.changePassword(account(req).user, input.currentPassword, input.password);
clearAccount(reply);
return { ok: true };
});
app.post('/api/auth/change-email', async (req) => {
const input = z
.object({ email: z.string().max(254), password: z.string().max(1024) })
.strict()
.parse(req.body);
await service.auth.changeEmail(account(req).user, input.email, input.password);
return { ok: true };
});
for (const view of ['inbox', 'vault', 'sent', 'favorites'] as const)
app.get(`/api/${view}`, async (req) => {
const input = z
.object({
cursor: z.string().max(512).optional(),
limit: z.coerce.number().int().min(1).max(100).default(30),
search: z.string().max(256).optional(),
kind: z.enum(['url', 'file']).optional(),
read: z.enum(['true', 'false']).optional(),
})
.parse(req.query);
return service.accounts.list(account(req, true).user.id, view, {
...input,
read: input.read === undefined ? undefined : input.read === 'true',
});
});
app.get('/api/vault/contents', async (req) => {
const input = z
.object({
cursor: z.string().max(512).optional(),
limit: z.coerce.number().int().min(1).max(100).default(30),
})
.parse(req.query);
return service.accounts.list(account(req, true).user.id, 'contents', input);
});
const noteInput = z
.string()
.trim()
.max(200)
.refine((value) => !/[\r\n]/.test(value))
.default('');
app.post('/api/vault', async (req) => {
const user = account(req, true).user;
const input = z
.discriminatedUnion('kind', [
z
.object({
kind: z.literal('url'),
urls: z.array(z.string().max(cfg.maxUrlBytes)).min(1).max(cfg.maxItems),
note: noteInput,
})
.strict(),
z
.object({
kind: z.literal('file'),
files: requestInput.shape.files.unwrap().min(1).max(cfg.maxItems),
note: noteInput,
})
.strict(),
])
.parse(req.body);
const result = service.accounts.idem(user.id, 'vault-create', key(req), input, () => {
const created = service.accounts.create(user.id, {
email: user.email!,
kind: input.kind,
count: input.kind === 'url' ? input.urls.length : input.files.length,
byteSize:
input.kind === 'url'
? Buffer.byteLength(input.urls.join('\n'))
: input.files.reduce((sum, file) => sum + file.size, 0),
...(input.kind === 'file' ? { files: input.files } : {}),
});
db.update(tables.requests)
.set({ note: input.note || null })
.where(eq(tables.requests.id, created.requestId))
.run();
if (input.kind === 'url')
service.accounts.complete(user.id, created.requestId, { urls: input.urls });
return created;
});
service.notifyTransfer(result.requestId);
return result;
});
app.patch('/api/vault/:id/note', async (req) => {
const input = z.object({ note: noteInput }).strict().parse(req.body);
service.vault.note(account(req, true).user.id, id(req), input.note);
return { ok: true };
});
app.post('/api/vault/:id/send', async (req) => {
const guest = auth(req),
current = account(req, true),
accessId = id(req);
const input = z
.object({ targetDeviceId: requestInput.shape.targetDeviceId })
.strict()
.parse(req.body);
const result = service.accounts.idem(
current.user.id,
`vault-send:${accessId}`,
key(req),
{ ...input, guestId: guest.id, sessionId: current.session.id },
() =>
service.vault.forward(guest, current, accessId, input.targetDeviceId, ip(req) ?? 'unknown'),
);
service.notifyTransfer(result.requestId);
return result;
});
app.patch('/api/access/:id/favorite', async (req) => {
account(req, true);
fail(410, 'PERMANENT_RETENTION_REMOVED', '모든 자료는 최대 7일간 보관됩니다.');
});
app.post('/api/access/:id/save-to-account', async (req) => {
account(req, true);
fail(410, 'GUEST_IMPORT_REMOVED', '로그인 중 받은 자료만 자동 기록합니다.');
});
app.get('/api/account/preferences', async (req) => ({
receivePolicy: account(req, true).user.receivePolicy,
}));
app.patch('/api/account/preferences', async (req) => {
const input = z
.object({ receivePolicy: z.enum(['requests', 'allowed', 'none']) })
.strict()
.parse(req.body),
user = account(req, true).user;
db.update(tables.users).set(input).where(eq(tables.users.id, user.id)).run();
service.accounts.notify(user.id);
return { ok: true };
});
app.get('/api/account/rules', async (req) => ({
rules: db
.select({
id: tables.rules.targetUserId,
action: tables.rules.action,
email: tables.users.email,
displayName: tables.users.displayName,
})
.from(tables.rules)
.innerJoin(tables.users, eq(tables.rules.targetUserId, tables.users.id))
.where(eq(tables.rules.userId, account(req, true).user.id))
.all(),
}));
app.put('/api/account/rules/:targetUserId', async (req) => {
const target = z.object({ targetUserId: z.uuid() }).parse(req.params).targetUserId,
input = z
.object({ action: z.enum(['allow', 'block']) })
.strict()
.parse(req.body);
service.accounts.rule(account(req, true).user.id, target, input.action);
return { ok: true };
});
app.delete('/api/account/rules/:targetUserId', async (req) => {
const target = z.object({ targetUserId: z.uuid() }).parse(req.params).targetUserId,
user = account(req, true).user;
db.delete(tables.rules)
.where(and(eq(tables.rules.userId, user.id), eq(tables.rules.targetUserId, target)))
.run();
service.accounts.notify(user.id);
return { ok: true };
});
app.get('/api/account/storage', async (req) => ({
usedBytes: service.accounts.used(account(req, true).user.id),
limitBytes: cfg.userQuota,
}));
const webRoot = cfg.webRoot;
if (webRoot && existsSync(webRoot)) {
await app.register(serveStatic, { root: webRoot, index: 'index.html' });
app.setNotFoundHandler((req, reply) => {
if (req.url.startsWith('/api/') || req.url.startsWith('/socket.io/'))
return reply
.status(404)
.send({ code: 'NOT_FOUND', message: '경로를 찾을 수 없습니다.', requestId: req.id });
return reply.sendFile('index.html');
});
}
let storageCleanup: Promise<void> | undefined;
const cleanup = setInterval(() => {
service.cleanup();
service.auth.enforceSessions();
storageCleanup = service.storage
.cleanup()
.catch(() => app.log.error({ code: 'CLEANUP_FAILED' }, 'Storage cleanup failed'));
}, 1000);
cleanup.unref();
app.addHook('preClose', async () => {
service.storage.close();
io.disconnectSockets(true);
});
app.addHook('onClose', async () => {
clearInterval(cleanup);
presence.close();
service.storage.close();
await storageCleanup;
await new Promise<void>((resolve) => io.close(() => resolve()));
sqlite.close();
});
return {
app,
io,
service,
sqlite,
setDevOrigin: (value: string) => {
devOrigin = value;
},
};
}

374
apps/server/src/auth.ts Normal file
View File

@@ -0,0 +1,374 @@
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { and, eq, gt, isNull, inArray } from 'drizzle-orm';
import argon2 from 'argon2';
import type { Service } from './service.js';
import type { Account } from '@hop/shared';
import * as s from './db/schema.js';
import { fail, hash, randomToken } from './security.js';
export type Mail = { to: string; subject: string; text: string; idempotencyKey?: string };
export type Mailer = (mail: Mail) => Promise<void>;
export type User = typeof s.users.$inferSelect;
const passwordOptions = { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 1 };
let dummy: Promise<string> | undefined;
export function normalizeEmail(value: string) {
const trimmed = value.trim();
const split = trimmed.lastIndexOf('@');
if (
split < 1 ||
split === trimmed.length - 1 ||
trimmed.length > 254 ||
/[\s\x00-\x1f\x7f]/.test(trimmed)
)
fail(400, 'EMAIL_INVALID', '이메일 주소를 확인해 주세요.');
const local = trimmed.slice(0, split),
domain = trimmed.slice(split + 1).toLowerCase();
if (
!/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(local) ||
!/^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain)
)
fail(400, 'EMAIL_INVALID', '이메일 주소를 확인해 주세요.');
return `${local}@${domain}`;
}
export class Auth {
readonly sendMail: Mailer;
readonly mailReady: boolean;
constructor(
readonly service: Service,
mailer?: Mailer,
) {
this.mailReady = Boolean(mailer);
this.sendMail =
mailer ??
(async () => fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.'));
if (service.cfg.registrationMode === 'open' && !this.mailReady)
throw Error('Mail provider must be configured before opening registration');
}
serialize(user: User): Account {
return {
id: user.id,
email: user.email ?? '',
displayName: user.status === 'deleted' ? '탈퇴한 사용자' : user.displayName,
role: user.role,
status: user.status,
verified: Boolean(user.verifiedAt && user.status === 'active'),
};
}
user(id: string) {
const user = this.service.db.select().from(s.users).where(eq(s.users.id, id)).get();
if (!user) fail(404, 'ACCOUNT_NOT_FOUND', '계정을 찾을 수 없습니다.');
return user!;
}
session(token: string | undefined) {
if (!token) return undefined;
const row = this.service.db
.select({ session: s.accountSessions, user: s.users })
.from(s.accountSessions)
.innerJoin(s.users, eq(s.accountSessions.userId, s.users.id))
.where(
and(
eq(s.accountSessions.tokenHash, hash(token)),
isNull(s.accountSessions.revokedAt),
gt(s.accountSessions.expiresAt, Date.now()),
inArray(s.users.status, ['pending', 'active']),
),
)
.get();
return row;
}
require(token: string | undefined, verified = false) {
const current = this.session(token);
if (!current) fail(401, 'LOGIN_REQUIRED', '로그인이 필요합니다.');
if (verified && !this.serialize(current!.user).verified)
fail(403, 'VERIFICATION_REQUIRED', '이메일 인증을 완료해 주세요.');
return current!;
}
async password(value: string) {
const minimum = this.service.cfg.production ? 12 : 1;
if (Array.from(value).length < minimum || value.length > 1024)
fail(400, 'PASSWORD_INVALID', `비밀번호는 ${minimum}~1024자로 입력해 주세요.`);
return argon2.hash(value, passwordOptions);
}
async token(user: User, purpose: 'verify' | 'reset' | 'email-change', value?: string) {
if (!this.mailReady)
fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.');
const token = randomToken(),
now = Date.now();
this.service.db.transaction(() => {
this.service.db
.update(s.accountTokens)
.set({ usedAt: now })
.where(
and(
eq(s.accountTokens.userId, user.id),
eq(s.accountTokens.purpose, purpose),
isNull(s.accountTokens.usedAt),
),
)
.run();
this.service.db
.insert(s.accountTokens)
.values({
id: randomUUID(),
userId: user.id,
purpose,
tokenHash: hash(token),
value,
createdAt: now,
expiresAt: now + (purpose === 'verify' ? 24 : 1) * 3600000,
})
.run();
});
const action =
purpose === 'verify'
? 'verify'
: purpose === 'reset'
? 'reset-password'
: 'verify-email-change';
await this.sendMail({
idempotencyKey: `auth-${purpose}-${hash(token)}`,
to: value ?? user.email!,
subject: purpose === 'reset' ? 'hop. 비밀번호 재설정' : 'hop. 이메일 인증',
text: `${purpose === 'reset' ? '비밀번호를 다시 설정하려면' : '이메일 인증을 완료하려면'} 다음 주소를 열어 주세요.\n${this.service.cfg.appUrl}/#${action}=${token}\n이 링크는 한 번만 사용할 수 있습니다.`,
});
}
async register(
email: string,
password: string,
displayName: string,
ip: string,
setupToken?: string,
) {
const { db, cfg, limiter } = this.service;
limiter.hit(`register-ip:${ip}`, 5, 3600000);
let admin = false;
if (setupToken) {
if (
!cfg.setupToken ||
hash(setupToken) !== hash(cfg.setupToken) ||
db.select().from(s.users).limit(1).get()
)
fail(403, 'SETUP_DISABLED', '초기화를 진행할 수 없습니다.');
admin = true;
} else if (cfg.registrationMode !== 'open')
fail(403, 'REGISTRATION_CLOSED', '현재 공개 가입이 닫혀 있습니다.');
if (!this.mailReady)
fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.');
const normalized = normalizeEmail(email),
passwordHash = await this.password(password);
if (db.select().from(s.users).where(eq(s.users.email, normalized)).get())
return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' };
const user = db.transaction(() => {
if (admin && db.select().from(s.users).limit(1).get())
fail(409, 'SETUP_DISABLED', '초기화가 이미 완료되었습니다.');
const id = randomUUID();
db.insert(s.users)
.values({
id,
email: normalized,
passwordHash,
displayName,
role: admin ? 'admin' : 'user',
status: 'pending',
createdAt: Date.now(),
})
.run();
return this.user(id);
});
await this.token(user, 'verify');
return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' };
}
consume(
token: string,
purpose: 'verify' | 'reset' | 'email-change',
fn: (token: typeof s.accountTokens.$inferSelect, user: User) => void,
) {
return this.service.db.transaction(() => {
const row = this.service.db
.select()
.from(s.accountTokens)
.where(
and(
eq(s.accountTokens.tokenHash, hash(token)),
eq(s.accountTokens.purpose, purpose),
isNull(s.accountTokens.usedAt),
gt(s.accountTokens.expiresAt, Date.now()),
),
)
.get();
if (!row) fail(400, 'TOKEN_INVALID', '링크가 유효하지 않거나 만료되었습니다.');
const user = this.user(row!.userId);
if (['suspended', 'deleted'].includes(user.status))
fail(400, 'TOKEN_INVALID', '링크가 유효하지 않거나 만료되었습니다.');
fn(row!, user);
this.service.db
.update(s.accountTokens)
.set({ usedAt: Date.now() })
.where(eq(s.accountTokens.id, row!.id))
.run();
return { ok: true };
});
}
verify(token: string) {
return this.consume(token, 'verify', (_t, user) => {
this.service.db
.update(s.users)
.set({ status: 'active', verifiedAt: Date.now() })
.where(eq(s.users.id, user.id))
.run();
});
}
async login(email: string, password: string, ip: string, oldToken?: string) {
const normalized = normalizeEmail(email);
this.service.limiter.hit(`login-ip:${ip}`, 20);
this.service.limiter.hit(`login-email:${hash(normalized)}`, 10);
const user = this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get();
dummy ??= argon2.hash(randomToken(), passwordOptions);
const valid = await argon2
.verify(user?.passwordHash ?? (await dummy), password)
.catch(() => false);
if (!valid || !user || !['pending', 'active'].includes(user.status))
fail(401, 'LOGIN_FAILED', '이메일 또는 비밀번호를 확인해 주세요.');
const token = randomToken(),
now = Date.now();
this.service.db.transaction(() => {
if (oldToken)
this.service.db
.update(s.accountSessions)
.set({ revokedAt: now })
.where(eq(s.accountSessions.tokenHash, hash(oldToken)))
.run();
this.service.db
.insert(s.accountSessions)
.values({
id: randomUUID(),
userId: user!.id,
tokenHash: hash(token),
createdAt: now,
lastSeen: now,
expiresAt: now + 30 * 86400000,
})
.run();
});
return { token, account: this.serialize(user!), expiresAt: now + 30 * 86400000 };
}
logout(token: string | undefined) {
const current = this.session(token);
if (!current) return;
this.service.storage.abortSession(current.session.id);
this.service.db
.update(s.accountSessions)
.set({ revokedAt: Date.now() })
.where(eq(s.accountSessions.id, current.session.id))
.run();
this.service.storage.abortSession(current.session.id);
this.service.io?.to(`account-session:${current.session.id}`).emit('account:revoked', {});
this.service.io
?.in(`account-session:${current.session.id}`)
.socketsLeave(`account:${current.user.id}`);
}
revoke(userId: string) {
this.service.storage.abortUser(userId);
this.service.storage.abortUser(userId);
this.service.db
.update(s.accountSessions)
.set({ revokedAt: Date.now() })
.where(eq(s.accountSessions.userId, userId))
.run();
this.service.io?.to(`account:${userId}`).emit('account:revoked', {});
this.service.io?.in(`account:${userId}`).socketsLeave(`account:${userId}`);
}
enforceSessions() {
for (const socket of this.service.io?.sockets.sockets.values() ?? []) {
const id = socket.data.accountSessionId;
if (!id) continue;
const session = this.service.db
.select({ session: s.accountSessions, user: s.users })
.from(s.accountSessions)
.innerJoin(s.users, eq(s.accountSessions.userId, s.users.id))
.where(eq(s.accountSessions.id, id))
.get();
if (
!session ||
session.session.revokedAt ||
session.session.expiresAt <= Date.now() ||
!['pending', 'active'].includes(session.user.status)
) {
socket.leave(`account:${socket.data.accountId}`);
socket.leave(`account-session:${id}`);
socket.data.accountId = undefined;
socket.data.accountSessionId = undefined;
socket.emit('account:revoked', {});
}
}
}
async resend(email: string, purpose: 'verify' | 'reset', ip: string) {
const normalized = normalizeEmail(email);
this.service.limiter.hit(`mail-ip:${ip}`, 10, 3600000);
this.service.limiter.hit(`mail-email:${hash(normalized)}`, 3, 3600000);
const user = this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get();
if (
user &&
((purpose === 'verify' && user.status === 'pending') ||
(purpose === 'reset' && ['active', 'pending'].includes(user.status)))
)
await this.token(user, purpose);
return { message: '처리 가능한 주소라면 안내 메일이 발송됩니다.' };
}
async resetPassword(token: string, password: string) {
const passwordHash = await this.password(password);
let userId = '';
const result = this.consume(token, 'reset', (_t, user) => {
userId = user.id;
this.service.db.update(s.users).set({ passwordHash }).where(eq(s.users.id, user.id)).run();
this.service.db
.update(s.accountTokens)
.set({ usedAt: Date.now() })
.where(eq(s.accountTokens.userId, user.id))
.run();
});
this.revoke(userId);
return result;
}
async changePassword(user: User, currentPassword: string, password: string) {
if (!user.passwordHash || !(await argon2.verify(user.passwordHash, currentPassword)))
fail(401, 'PASSWORD_INVALID', '현재 비밀번호를 확인해 주세요.');
const passwordHash = await this.password(password);
this.service.db.update(s.users).set({ passwordHash }).where(eq(s.users.id, user.id)).run();
this.revoke(user.id);
}
async changeEmail(user: User, email: string, password: string) {
if (!user.passwordHash || !(await argon2.verify(user.passwordHash, password)))
fail(401, 'PASSWORD_INVALID', '현재 비밀번호를 확인해 주세요.');
const normalized = normalizeEmail(email);
if (
normalized === user.email ||
this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get()
)
fail(400, 'EMAIL_UNAVAILABLE', '이 주소로 변경할 수 없습니다.');
await this.token(user, 'email-change', normalized);
}
async verifyEmailChange(token: string) {
let previous = '',
next = '';
const result = this.consume(token, 'email-change', (t, user) => {
if (
!t.value ||
this.service.db.select().from(s.users).where(eq(s.users.email, t.value)).get()
)
fail(400, 'EMAIL_UNAVAILABLE', '이 주소로 변경할 수 없습니다.');
previous = user.email!;
next = t.value!;
this.service.db
.update(s.users)
.set({ email: t.value, verifiedAt: Date.now(), status: 'active' })
.where(eq(s.users.id, user.id))
.run();
});
await this.sendMail({
to: previous,
subject: 'hop. 이메일 주소 변경 안내',
text: `계정 이메일이 ${next}로 변경되었습니다.`,
});
return result;
}
}

106
apps/server/src/config.ts Normal file
View File

@@ -0,0 +1,106 @@
import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto';
export type Config = {
production: boolean;
appUrl: string;
databasePath: string;
sessionSecret: string;
discoverySecret: string;
discoveryEnabled: boolean;
trustedProxies: string[];
port: number;
host: string;
guestDays: number;
requestSeconds: number;
uploadStartHours: number;
spaceMinutes: number;
spaceMax: number;
offlineGraceMs: number;
maxUrlBytes: number;
maxItems: number;
urlRetentionDays: number;
sessionRate: number;
ipRate: number;
pairRate: number;
rejectCooldownMs: number;
uploadDir: string;
maxFileBytes: number;
guestQuota: number;
totalGuestQuota: number;
minFreeBytes: number;
fileRetentionDays: number;
orphanHours: number;
undoSeconds: number;
registrationMode: 'open' | 'closed';
setupToken?: string;
mailFrom: string;
resendApiKey?: string;
userQuota: number;
emailRequestDays: number;
webRoot?: string;
};
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const production = env.NODE_ENV === 'production';
const num = (key: string, fallback: number, min = 1) => {
const n = Number(env[key] ?? fallback);
if (!Number.isSafeInteger(n) || n < min) throw Error(`Invalid ${key}`);
return n;
};
const secret = (key: string) => {
const v = env[key];
if (production && (!v || v.length < 32 || v.startsWith('replace-')))
throw Error(`${key} must be a random secret (32+ characters)`);
return v && v.length >= 32 ? v : randomBytes(32).toString('base64url');
};
const port = num('PORT', 0, 0);
if (production && port === 0) throw Error('Set PORT explicitly before deployment');
const appUrl = env.APP_URL ?? 'http://127.0.0.1';
if (production && !appUrl.startsWith('https://'))
throw Error('Production APP_URL must use HTTPS');
return {
production,
appUrl,
databasePath:
env.DATABASE_PATH ?? fileURLToPath(new URL('../../../.data/hop.sqlite', import.meta.url)),
sessionSecret: secret('SESSION_SECRET'),
discoverySecret: secret('DISCOVERY_SECRET'),
discoveryEnabled:
env.SAME_NETWORK_DISCOVERY_ENABLED === undefined
? !production
: env.SAME_NETWORK_DISCOVERY_ENABLED === 'true',
trustedProxies: (env.TRUSTED_PROXY_CIDRS ?? '')
.split(',')
.map((v) => v.trim())
.filter(Boolean),
port,
host: env.HOST ?? '127.0.0.1',
guestDays: num('GUEST_SESSION_RETENTION_DAYS', 30),
requestSeconds: num('LOCAL_REQUEST_TIMEOUT_SECONDS', 60),
uploadStartHours: num('ACCEPTED_UPLOAD_START_HOURS', 24),
spaceMinutes: num('SPACE_RETENTION_MINUTES', 10),
spaceMax: num('SPACE_MAX_DEVICES', 10),
offlineGraceMs: num('OFFLINE_GRACE_MS', 3000, 0),
maxUrlBytes: num('MAX_URL_PAYLOAD_SIZE_KB', 1024) * 1024,
maxItems: num('MAX_ITEMS_PER_TRANSFER', 20),
urlRetentionDays: Math.min(7, num('DEFAULT_URL_RETENTION_DAYS', 7)),
sessionRate: num('GUEST_REQUESTS_PER_MINUTE', 5),
ipRate: num('IP_REQUESTS_PER_MINUTE', 20),
pairRate: num('PAIR_REQUESTS_PER_MINUTE', 2),
rejectCooldownMs: num('REJECT_COOLDOWN_SECONDS', 300) * 1000,
uploadDir: env.UPLOAD_DIR ?? fileURLToPath(new URL('../../../.data/uploads', import.meta.url)),
maxFileBytes: num('MAX_FILE_SIZE_MB', 2048) * 1048576,
guestQuota: num('GUEST_STORAGE_LIMIT_MB', 2048) * 1048576,
totalGuestQuota: num('TOTAL_GUEST_STORAGE_LIMIT_MB', 20480) * 1048576,
minFreeBytes: num('MIN_FREE_DISK_MB', 5120, 0) * 1048576,
fileRetentionDays: Math.min(7, num('DEFAULT_FILE_RETENTION_DAYS', 7)),
orphanHours: num('ORPHAN_UPLOAD_RETENTION_HOURS', 24),
undoSeconds: num('DELETE_UNDO_SECONDS', 5),
registrationMode: env.REGISTRATION_MODE === 'open' ? 'open' : 'closed',
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,
emailRequestDays: num('EMAIL_REQUEST_RETENTION_DAYS', 7),
webRoot: env.WEB_ROOT,
};
}

View File

@@ -0,0 +1,57 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import { sql } from 'drizzle-orm';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from './schema.js';
export function openDatabase(path: string) {
if (path !== ':memory:') mkdirSync(dirname(resolve(path)), { recursive: true });
const sqlite = new Database(path);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('busy_timeout = 5000');
const db = drizzle(sqlite, { schema });
// SQLite table rebuilds require foreign_keys OFF outside the transaction. Check all references
// before committing, then restore enforcement before accepting any application request.
sqlite.pragma('foreign_keys = OFF');
try {
sqlite.exec(
'CREATE TABLE IF NOT EXISTS __drizzle_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL, created_at INTEGER)',
);
const migrations = readMigrationFiles({
migrationsFolder: fileURLToPath(new URL('../../drizzle', import.meta.url)),
});
db.transaction((tx) => {
const latest = sqlite
.prepare('SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1')
.get() as { created_at: number } | undefined;
for (const migration of migrations) {
if (latest && migration.folderMillis <= latest.created_at) continue;
// A development server applied the first account schema before its deletion-column
// correction was generated. Preserve that ledger/data; migration 0005 applies the sole
// remaining structural difference. Accept only this exact observed legacy revision.
const legacy = sqlite
.prepare('SELECT hash FROM __drizzle_migrations WHERE created_at = ?')
.get(1791448938074) as { hash: string } | undefined;
const legacyAccountRevision =
migration === migrations[4] &&
legacy?.hash === 'fb1af44cf19e4f77c50f07608ba046819dce40cae3925e0b632d1d9afa045068';
if (!legacyAccountRevision)
for (const statement of migration.sql) tx.run(sql.raw(statement));
sqlite
.prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)')
.run(migration.hash, migration.folderMillis);
}
if ((sqlite.pragma('foreign_key_check') as unknown[]).length)
throw Error('Migration foreign-key check failed');
});
} catch (error) {
sqlite.close();
throw error;
} finally {
if (sqlite.open) sqlite.pragma('foreign_keys = ON');
}
return { db, sqlite };
}
export type DB = ReturnType<typeof openDatabase>['db'];

View File

@@ -0,0 +1,354 @@
import { sqliteTable, text, integer, index, uniqueIndex, check } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text().primaryKey(),
email: text().unique(),
displayName: text().notNull(),
passwordHash: text(),
role: text({ enum: ['user', 'admin'] })
.notNull()
.default('user'),
status: text({ enum: ['pending', 'active', 'suspended', 'deleted'] }).notNull(),
verifiedAt: integer(),
createdAt: integer().notNull(),
receivePolicy: text({ enum: ['requests', 'allowed', 'none'] })
.notNull()
.default('requests'),
});
export const accountSessions = sqliteTable(
'account_sessions',
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => users.id),
tokenHash: text().notNull().unique(),
createdAt: integer().notNull(),
lastSeen: integer().notNull(),
expiresAt: integer().notNull(),
revokedAt: integer(),
},
(t) => [index('account_session_user').on(t.userId, t.expiresAt)],
);
export const accountTokens = sqliteTable(
'account_tokens',
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => users.id),
purpose: text({ enum: ['verify', 'reset', 'email-change'] }).notNull(),
tokenHash: text().notNull().unique(),
value: text(),
createdAt: integer().notNull(),
expiresAt: integer().notNull(),
usedAt: integer(),
},
(t) => [index('account_token_expiry').on(t.expiresAt)],
);
export const rules = sqliteTable(
'user_rules',
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => users.id),
targetUserId: text()
.notNull()
.references(() => users.id),
action: text({ enum: ['allow', 'block'] }).notNull(),
},
(t) => [
uniqueIndex('user_rule_pair').on(t.userId, t.targetUserId),
check('rule_not_self', sql`${t.userId} <> ${t.targetUserId}`),
],
);
export const guests = sqliteTable(
'guest_sessions',
{
id: text().primaryKey(),
tokenHash: text().notNull().unique(),
expiresAt: integer().notNull(),
revokedAt: integer(),
createdAt: integer().notNull(),
},
(t) => [index('guest_expiry').on(t.expiresAt)],
);
export const devices = sqliteTable(
'browser_devices',
{
id: text().primaryKey(),
guestId: text()
.notNull()
.unique()
.references(() => guests.id),
name: text().notNull(),
code: text().notNull().unique(),
discoverable: integer({ mode: 'boolean' }).notNull().default(true),
lastSeen: integer().notNull(),
},
(t) => [check('device_name_length', sql`length(${t.name}) between 1 and 40`)],
);
export const spaces = sqliteTable(
'connection_spaces',
{
id: text().primaryKey(),
creatorGuestId: text()
.notNull()
.references(() => guests.id),
tokenHash: text().notNull().unique(),
codeHash: text().notNull().unique(),
approvalRequired: integer({ mode: 'boolean' }).notNull(),
expiresAt: integer().notNull(),
closedAt: integer(),
createdAt: integer().notNull(),
},
(t) => [index('space_expiry').on(t.expiresAt)],
);
export const members = sqliteTable(
'space_members',
{
id: text().primaryKey(),
spaceId: text()
.notNull()
.references(() => spaces.id),
deviceId: text()
.notNull()
.references(() => devices.id),
status: text({ enum: ['pending', 'approved', 'removed'] }).notNull(),
joinedAt: integer().notNull(),
},
(t) => [
uniqueIndex('space_device').on(t.spaceId, t.deviceId),
uniqueIndex('member_single_active_device')
.on(t.deviceId)
.where(sql`${t.status} IN ('pending', 'approved')`),
],
);
export const blocks = sqliteTable(
'guest_blocks',
{
id: text().primaryKey(),
guestId: text()
.notNull()
.references(() => guests.id),
targetGuestId: text()
.notNull()
.references(() => guests.id),
},
(t) => [uniqueIndex('guest_block_pair').on(t.guestId, t.targetGuestId)],
);
export const requests = sqliteTable(
'transfer_requests',
{
id: text().primaryKey(),
mode: text({ enum: ['local', 'email'] })
.notNull()
.default('local'),
senderGuestId: text().references(() => guests.id),
recipientGuestId: text().references(() => guests.id),
senderUserId: text().references(() => users.id),
recipientUserId: text().references(() => users.id),
kind: text({ enum: ['text', 'url', 'file'] }).notNull(),
byteSize: integer().notNull(),
manifest: text(),
note: text(),
count: integer().notNull(),
status: text({ enum: ['pending', 'accepted', 'rejected', 'expired', 'cancelled'] }).notNull(),
createdAt: integer().notNull(),
expiresAt: integer().notNull(),
acceptedAt: integer(),
rejectedAt: integer(),
startBy: integer(),
},
(t) => [
index('requests_sender_time').on(t.senderGuestId, t.createdAt),
index('requests_recipient_time').on(t.recipientGuestId, t.createdAt),
index('requests_expiry').on(t.status, t.expiresAt),
check(
'request_different_guests',
sql`${t.mode} <> 'local' OR ${t.senderGuestId} <> ${t.recipientGuestId}`,
),
check(
'request_sender_subject',
sql`(${t.senderGuestId} IS NOT NULL) + (${t.senderUserId} IS NOT NULL) = 1`,
),
check(
'request_recipient_subject',
sql`(${t.recipientGuestId} IS NOT NULL) + (${t.recipientUserId} IS NOT NULL) = 1`,
),
check(
'request_mode_subjects',
sql`(${t.mode} = 'local' AND ${t.senderGuestId} IS NOT NULL AND ${t.recipientGuestId} IS NOT NULL) OR (${t.mode} = 'email' AND ${t.senderUserId} IS NOT NULL AND ${t.recipientUserId} IS NOT NULL)`,
),
index('requests_sender_user').on(t.senderUserId, t.createdAt),
index('requests_recipient_user').on(t.recipientUserId, t.createdAt),
],
);
export const batches = sqliteTable('transfer_batches', {
id: text().primaryKey(),
requestId: text()
.notNull()
.unique()
.references(() => requests.id),
status: text({ enum: ['waiting', 'uploading', 'stored', 'failed'] }).notNull(),
storedAt: integer(),
});
export const files = sqliteTable(
'files',
{
id: text().primaryKey(),
requestId: text()
.notNull()
.references(() => requests.id),
ownerGuestId: text().references(() => guests.id),
ownerUserId: text().references(() => users.id),
position: integer().notNull(),
storageKey: text().notNull().unique(),
name: text().notNull(),
type: text().notNull(),
size: integer().notNull(),
checksum: text(),
status: text({ enum: ['pending', 'uploading', 'stored', 'failed', 'cleaning'] }).notNull(),
uploadId: text(),
uploadKey: text(),
updatedAt: integer().notNull(),
},
(t) => [
uniqueIndex('request_file_position').on(t.requestId, t.position),
index('files_owner_state').on(t.ownerGuestId, t.status),
check('file_nonnegative_size', sql`${t.size} >= 0`),
check(
'file_owner_subject',
sql`(${t.ownerGuestId} IS NOT NULL) + (${t.ownerUserId} IS NOT NULL) = 1`,
),
],
);
export const reservations = sqliteTable(
'quota_reservations',
{
id: text().primaryKey(),
fileId: text()
.notNull()
.unique()
.references(() => files.id),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
bytes: integer().notNull(),
expiresAt: integer().notNull(),
},
(t) => [
check('reservations_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
index('quota_guest_expiry').on(t.guestId, t.expiresAt),
check('reservation_nonnegative', sql`${t.bytes} >= 0`),
],
);
export const deletions = sqliteTable(
'deletion_operations',
{
id: text().primaryKey(),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
undoUntil: integer().notNull(),
createdAt: integer().notNull(),
restoredAt: integer(),
},
(t) => [
check('deletion_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
],
);
export const deletionTargets = sqliteTable('deletion_targets', {
id: text().primaryKey(),
operationId: text()
.notNull()
.references(() => deletions.id),
accessId: text().notNull(),
});
export const items = sqliteTable(
'items',
{
id: text().primaryKey(),
batchId: text()
.notNull()
.references(() => batches.id),
kind: text({ enum: ['text', 'url', 'file'] }).notNull(),
text: text(),
url: text(),
fileId: text().references(() => files.id),
createdAt: integer().notNull(),
expiresAt: integer().notNull(),
},
(t) => [
index('items_expiry').on(t.expiresAt),
check(
'item_payload',
sql`(${t.kind} = 'text' AND ${t.text} IS NOT NULL AND ${t.url} IS NULL AND ${t.fileId} IS NULL) OR (${t.kind} = 'url' AND ${t.url} IS NOT NULL AND ${t.text} IS NULL AND ${t.fileId} IS NULL) OR (${t.kind} = 'file' AND ${t.fileId} IS NOT NULL AND ${t.text} IS NULL AND ${t.url} IS NULL)`,
),
],
);
export const access = sqliteTable(
'item_access',
{
id: text().primaryKey(),
itemId: text()
.notNull()
.references(() => items.id),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
role: text({ enum: ['sender', 'recipient', 'self'] }).notNull(),
read: integer({ mode: 'boolean' }).notNull().default(false),
deletedAt: integer(),
favorite: integer({ mode: 'boolean' }).notNull().default(false),
note: text(),
},
(t) => [
check('access_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
uniqueIndex('access_subject_role').on(t.itemId, t.guestId, t.role),
uniqueIndex('access_account_role').on(t.itemId, t.userId, t.role),
index('access_account').on(t.userId, t.deletedAt),
index('access_guest').on(t.guestId, t.deletedAt),
],
);
export const receiptAccounts = sqliteTable('request_receipt_accounts', {
requestId: text()
.primaryKey()
.references(() => requests.id),
userId: text()
.notNull()
.references(() => users.id),
sessionId: text()
.notNull()
.references(() => accountSessions.id),
});
export const vaultTransfers = sqliteTable('vault_transfers', {
requestId: text()
.primaryKey()
.references(() => requests.id),
accessId: text()
.notNull()
.references(() => access.id),
userId: text()
.notNull()
.references(() => users.id),
sessionId: text()
.notNull()
.references(() => accountSessions.id),
});
export const idempotency = sqliteTable(
'idempotency_records',
{
id: text().primaryKey(),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
operation: text().notNull(),
key: text().notNull(),
payloadHash: text().notNull(),
response: text().notNull(),
createdAt: integer().notNull(),
},
(t) => [
uniqueIndex('idempotency_scope').on(t.guestId, t.operation, t.key),
uniqueIndex('account_idempotency_scope').on(t.userId, t.operation, t.key),
check('idempotency_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
],
);

48
apps/server/src/mail.ts Normal file
View File

@@ -0,0 +1,48 @@
import { randomUUID } from 'node:crypto';
import type { Config } from './config.js';
import type { Mailer } from './auth.js';
import { ApiError } from './security.js';
/** The API key stays in the server process. Delivery receipts and provider error bodies
* never become public API responses or application logs. */
export function createResendMailer(
cfg: Pick<Config, 'resendApiKey' | 'mailFrom'>,
fetcher: typeof fetch = fetch,
): Mailer | undefined {
if (!cfg.resendApiKey) return undefined;
return async (mail) => {
try {
const response = await fetcher('https://api.resend.com/emails', {
method: 'POST',
redirect: 'error',
headers: {
Authorization: `Bearer ${cfg.resendApiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': mail.idempotencyKey ?? randomUUID(),
},
body: JSON.stringify({
from: cfg.mailFrom,
to: [mail.to],
subject: mail.subject,
text: mail.text,
}),
signal: AbortSignal.timeout(10000),
});
if (!response.ok) throw new Error('Delivery rejected');
const receipt: unknown = await response.json();
if (
!receipt ||
typeof receipt !== 'object' ||
!('id' in receipt) ||
typeof receipt.id !== 'string'
)
throw new Error('Invalid receipt');
} catch {
throw new ApiError(
503,
'MAIL_DELIVERY_FAILED',
'메일을 보내지 못했습니다. 잠시 후 다시 시도해 주세요.',
);
}
};
}

13
apps/server/src/main.ts Normal file
View File

@@ -0,0 +1,13 @@
import { loadConfig } from './config.js';
import { createApp } from './app.js';
const cfg = loadConfig();
const runtime = await createApp(cfg);
await runtime.app.listen({ port: cfg.port, host: cfg.host });
const address = runtime.app.server.address();
if (!address || typeof address === 'string') throw Error('No server address');
if (!cfg.production) runtime.setDevOrigin(`http://${cfg.host}:${address.port}`);
process.stdout.write(`hop. ready at http://${cfg.host}:${address.port}\n`);
for (const signal of ['SIGTERM', 'SIGINT'] as const)
process.on(signal, () => {
void runtime.app.close();
});

View File

@@ -0,0 +1,77 @@
import type { Server, Socket } from 'socket.io';
import type { Config } from './config.js';
import { hmac, publicIp } from './security.js';
export class Presence {
private connections = new Map<string, Map<string, string | null>>();
private graceIps = new Map<string, Set<string | null>>();
private timers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
private io: Server,
private cfg: Config,
private changed: () => void,
) {}
connect(guestId: string, socket: Socket, ip: string | null) {
const timer = this.timers.get(guestId);
if (timer) clearTimeout(timer);
this.timers.delete(guestId);
this.graceIps.delete(guestId);
const sockets = this.connections.get(guestId) ?? new Map();
sockets.set(socket.id, ip);
this.connections.set(guestId, sockets);
socket.join(`guest:${guestId}`);
this.changed();
socket.on('disconnect', () => this.disconnect(guestId, socket.id));
}
private disconnect(guestId: string, id: string) {
const sockets = this.connections.get(guestId);
const oldIp = sockets?.get(id) ?? null;
sockets?.delete(id);
if (sockets?.size) return;
this.graceIps.set(guestId, new Set([oldIp]));
const timer = setTimeout(() => {
this.connections.delete(guestId);
this.graceIps.delete(guestId);
this.timers.delete(guestId);
this.changed();
}, this.cfg.offlineGraceMs);
timer.unref();
this.timers.set(guestId, timer);
}
online(id: string) {
return this.connections.has(id);
}
active(id: string) {
return Boolean(this.connections.get(id)?.size);
}
sameNetwork(a: string, b: string) {
if (!this.cfg.discoveryEnabled) return false;
const groups = (id: string) =>
new Set(
[...(this.connections.get(id)?.values() ?? []), ...(this.graceIps.get(id) ?? [])]
.filter(
(ip) => publicIp(ip) || (!this.cfg.production && (ip === '127.0.0.1' || ip === '::1')),
)
.map((ip) => hmac(this.cfg.discoverySecret, ip!)),
);
const ag = groups(a);
return [...groups(b)].some((v) => ag.has(v));
}
emit(id: string, event: string, payload: unknown) {
this.io.to(`guest:${id}`).emit(event, payload);
}
ids() {
return [...this.connections.keys()].filter((id) => this.online(id));
}
revoke(id: string) {
this.io.in(`guest:${id}`).disconnectSockets(true);
this.connections.delete(id);
this.graceIps.delete(id);
const timer = this.timers.get(id);
if (timer) clearTimeout(timer);
this.timers.delete(id);
this.changed();
}
close() {
for (const t of this.timers.values()) clearTimeout(t);
}
}

View File

@@ -0,0 +1,84 @@
import { createHash, createHmac, randomBytes } from 'node:crypto';
import ipaddr from 'ipaddr.js';
import type { IncomingHttpHeaders } from 'node:http';
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
) {
super(message);
}
}
export const fail = (status: number, code: string, message: string): never => {
throw new ApiError(status, code, message);
};
export const randomToken = () => randomBytes(32).toString('base64url');
export const hash = (value: string) => createHash('sha256').update(value).digest('hex');
export const hmac = (secret: string, value: string) =>
createHmac('sha256', secret).update(value).digest('hex');
export function normalizeIp(value: string): string | null {
try {
return ipaddr.process(value).toString();
} catch {
return null;
}
}
export function clientIp(
remote: string | undefined,
headers: IncomingHttpHeaders,
trusted: string[],
): string | null {
const first = remote ? normalizeIp(remote) : null;
if (!first) return null;
const trusts = (v: string) =>
trusted.some((rule) => {
try {
const a = ipaddr.process(v);
const cidr = rule.includes('/')
? ipaddr.parseCIDR(rule)
: ([ipaddr.process(rule), a.kind() === 'ipv4' ? 32 : 128] as [
ipaddr.IPv4 | ipaddr.IPv6,
number,
]);
return a.kind() === cidr[0].kind() && a.match(cidr);
} catch {
return false;
}
});
if (!trusts(first)) return first;
const raw = headers['x-forwarded-for'];
if (typeof raw !== 'string') return null;
const chain = raw.split(',').map((v) => normalizeIp(v.trim()));
if (chain.some((v) => !v)) return null;
let current = first;
for (const next of chain.reverse()) {
if (!trusts(current)) break;
current = next!;
}
return current;
}
export function publicIp(ip: string | null): boolean {
if (!ip) return false;
try {
return ipaddr.process(ip).range() === 'unicast';
} catch {
return false;
}
}
export class Limiter {
private windows = new Map<string, { count: number; until: number }>();
hit(key: string, max: number, windowMs = 60000) {
const now = Date.now();
let w = this.windows.get(key);
if (!w || w.until <= now) {
w = { count: 0, until: now + windowMs };
this.windows.set(key, w);
}
if (++w.count > max) fail(429, 'RATE_LIMIT', '요청이 많습니다. 잠시 후 다시 시도해 주세요.');
}
prune() {
const now = Date.now();
for (const [key, w] of this.windows) if (w.until <= now) this.windows.delete(key);
}
}

1047
apps/server/src/service.ts Normal file

File diff suppressed because it is too large Load Diff

596
apps/server/src/storage.ts Normal file
View File

@@ -0,0 +1,596 @@
import { createHash, randomUUID } from 'node:crypto';
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 { pipeline } from 'node:stream/promises';
import { and, eq, gt, isNull, inArray, sql, or } from 'drizzle-orm';
import sharp from 'sharp';
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';
export type StorageSubject = { id: string; kind?: 'user'; sessionId?: string };
export class Storage {
private active = new Map<
string,
{ guestId: string; controller: AbortController; fileId: string; sessionId?: string }
>();
private cleaning = false;
private cleanupPromise: Promise<void> | undefined;
readonly temp: string;
readonly originals: string;
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 });
}
private request(g: StorageSubject, id: string) {
return g.kind === 'user'
? this.service.accounts.request(g.id, id)
: this.service.request(g as Guest, id);
}
private access(g: StorageSubject, id: string) {
return g.kind === 'user'
? this.service.accounts.access(g.id, id)
: this.service.accessDetail(g as Guest, id);
}
private blocked(g: StorageSubject, row: ReturnType<Storage['request']>) {
return g.kind === 'user'
? !this.service.accounts.canTransfer(row)
: this.service.isBlocked(g.id, row.recipientGuestId!);
}
private isSender(g: StorageSubject, row: ReturnType<Storage['request']>) {
return g.kind === 'user' ? row.senderUserId === g.id : row.senderGuestId === g.id;
}
fileRows(id: string) {
return this.service.db.select().from(s.files).where(eq(s.files.requestId, id)).all();
}
createManifest(
g: StorageSubject,
requestId: string,
manifest: { name: string; size: number; type: string }[],
) {
for (const [position, file] of manifest.entries())
this.service.db
.insert(s.files)
.values({
id: randomUUID(),
requestId,
ownerGuestId: g.kind === 'user' ? null : g.id,
ownerUserId: g.kind === 'user' ? g.id : null,
position,
name: file.name,
size: file.size,
type: file.type,
storageKey: randomUUID(),
status: 'pending',
updatedAt: Date.now(),
})
.run();
}
private used(guestId?: string) {
return this.service.db
.select({ bytes: sql<number>`coalesce(sum(${s.files.size}),0)` })
.from(s.files)
.where(
and(
inArray(s.files.status, ['stored', 'cleaning']),
sql`${s.files.ownerGuestId} IS NOT NULL`,
guestId ? eq(s.files.ownerGuestId, guestId) : undefined,
),
)
.get()!.bytes;
}
private reserved(guestId?: string) {
return this.service.db
.select({ bytes: sql<number>`coalesce(sum(${s.reservations.bytes}),0)` })
.from(s.reservations)
.where(
and(
gt(s.reservations.expiresAt, Date.now()),
sql`${s.reservations.guestId} IS NOT NULL`,
guestId ? eq(s.reservations.guestId, guestId) : undefined,
),
)
.get()!.bytes;
}
reserve(g: StorageSubject, requestId: string) {
const { db, cfg } = this.service;
const files = this.fileRows(requestId).filter((f) => f.status !== 'stored');
const missing = files.filter(
(file) =>
!db
.select()
.from(s.reservations)
.where(and(eq(s.reservations.fileId, file.id), gt(s.reservations.expiresAt, Date.now())))
.get(),
);
const total = missing.reduce((sum, file) => sum + file.size, 0);
if (g.kind === 'user') {
const reserved = db
.select({ bytes: sql<number>`coalesce(sum(${s.reservations.bytes}),0)` })
.from(s.reservations)
.where(and(eq(s.reservations.userId, g.id), gt(s.reservations.expiresAt, Date.now())))
.get()!.bytes;
this.service.accounts.ensureQuota(g.id, reserved + total);
}
if (g.kind !== 'user' && this.used(g.id) + this.reserved(g.id) + total > cfg.guestQuota)
fail(413, 'GUEST_QUOTA', '이번 기기의 저장 용량을 초과했습니다.');
if (g.kind !== 'user' && this.used() + this.reserved() + total > cfg.totalGuestQuota)
fail(507, 'TOTAL_QUOTA', '현재 서버의 익명 저장 공간이 부족합니다.');
const allReserved = db
.select({ bytes: sql<number>`coalesce(sum(${s.reservations.bytes}),0)` })
.from(s.reservations)
.where(gt(s.reservations.expiresAt, Date.now()))
.get()!.bytes;
const disk = statfsSync(this.originals, { bigint: true });
if (BigInt(allReserved + total + cfg.minFreeBytes) > disk.bavail * disk.bsize)
fail(507, 'DISK_FULL', '서버의 여유 공간이 부족합니다.');
for (const file of missing)
db.insert(s.reservations)
.values({
id: randomUUID(),
fileId: file.id,
guestId: g.kind === 'user' ? null : g.id,
userId: g.kind === 'user' ? g.id : null,
bytes: file.size,
expiresAt: Date.now() + cfg.orphanHours * 3600000,
})
.onConflictDoUpdate({
target: s.reservations.fileId,
set: { expiresAt: Date.now() + cfg.orphanHours * 3600000 },
})
.run();
}
release(requestId: string) {
const db = this.service.db;
for (const file of this.fileRows(requestId)) {
for (const [uploadId, upload] of this.active)
if (upload.fileId === file.id) {
upload.controller.abort();
this.active.delete(uploadId);
}
db.delete(s.reservations).where(eq(s.reservations.fileId, file.id)).run();
if (file.status !== 'stored')
db.update(s.files)
.set({ status: 'failed', updatedAt: Date.now() })
.where(eq(s.files.id, file.id))
.run();
}
}
async upload(
g: StorageSubject,
requestId: string,
position: number,
request: FastifyRequest,
key: string,
) {
const { db, cfg } = this.service;
let file: typeof s.files.$inferSelect;
let uploadId: string;
const existing = this.fileRows(requestId).find((f) => f.position === position);
if (existing?.status === 'stored') {
const row = this.request(g, requestId);
if (!this.isSender(g, row)) fail(403, 'UPLOAD_FORBIDDEN', '발신자만 업로드할 수 있습니다.');
if (existing.uploadKey !== key) fail(409, 'FILE_STORED', '이미 저장된 파일입니다.');
const checksum = createHash('sha256');
let bytes = 0,
count = 0;
for await (const part of request.parts({
limits: { files: 1, fields: 0, parts: 1, fileSize: cfg.maxFileBytes + 1 },
})) {
if (part.type !== 'file')
throw new ApiError(400, 'MULTIPART_INVALID', '파일 한 개가 필요합니다.');
count++;
if (part.filename !== existing.name)
fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.');
for await (const chunk of part.file) {
bytes += chunk.length;
if (bytes > existing.size)
fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.');
checksum.update(chunk);
}
}
if (count !== 1 || bytes !== existing.size || checksum.digest('hex') !== existing.checksum)
fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.');
return { uploadId: existing.uploadId!, fileId: existing.id, status: 'stored' };
}
db.transaction(() => {
const row = this.request(g, requestId);
if (row.kind !== 'file' || !this.isSender(g, row))
fail(403, 'UPLOAD_FORBIDDEN', '이 요청에 파일을 올릴 수 없습니다.');
if (
row.status !== 'accepted' ||
!row.startBy ||
row.startBy <= Date.now() ||
this.blocked(g, row)
)
fail(409, 'ACCEPTANCE_REQUIRED', '수락된 유효한 요청이 필요합니다.');
file = this.fileRows(requestId).find((f) => f.position === position)!;
if (!file) fail(400, 'FILE_NOT_REQUESTED', '요청에 없는 파일입니다.');
if (file.status === 'stored') fail(409, 'FILE_STORED', '이미 저장된 파일입니다.');
if (
file.status === 'uploading' ||
[...this.active.values()].filter((v) => v.guestId === `${g.kind ?? 'guest'}:${g.id}`)
.length >= (g.kind === 'user' ? 2 : 1)
)
fail(409, 'UPLOAD_ACTIVE', '진행 중인 업로드를 먼저 끝내 주세요.');
this.reserve(g, requestId);
uploadId = randomUUID();
db.update(s.files)
.set({ status: 'uploading', uploadId, uploadKey: key, updatedAt: Date.now() })
.where(eq(s.files.id, file.id))
.run();
db.update(s.batches)
.set({ status: 'uploading' })
.where(eq(s.batches.requestId, requestId))
.run();
});
const controller = new AbortController(),
tempPath = resolve(this.temp, uploadId!);
this.active.set(uploadId!, {
guestId: `${g.kind ?? 'guest'}:${g.id}`,
controller,
sessionId: g.sessionId,
fileId: file!.id,
});
request.raw.once('aborted', () => controller.abort());
this.service.notifyTransfer(requestId);
try {
let parts = 0;
let bytes = 0;
const checksum = createHash('sha256');
for await (const part of request.parts({
limits: { files: 1, fields: 0, parts: 1, fileSize: cfg.maxFileBytes + 1 },
})) {
if (part.type !== 'file')
throw new ApiError(400, 'MULTIPART_INVALID', '파일 한 개만 업로드해 주세요.');
parts++;
if (part.filename !== file!.name)
fail(409, 'METADATA_MISMATCH', '요청한 파일 이름과 다릅니다.');
const counter = new Transform({
transform(chunk, _encoding, callback) {
bytes += chunk.length;
if (bytes > file!.size || bytes > cfg.maxFileBytes)
return callback(
new ApiError(413, 'FILE_SIZE_MISMATCH', '파일 크기가 요청과 다릅니다.'),
);
checksum.update(chunk);
callback(null, chunk);
},
});
await pipeline(
part.file,
counter,
createWriteStream(tempPath, { flags: 'wx', mode: 0o600 }),
{ signal: controller.signal },
);
if (part.file.truncated || bytes !== file!.size)
fail(409, 'FILE_SIZE_MISMATCH', '파일 크기가 요청과 다릅니다.');
}
if (parts !== 1) fail(400, 'MULTIPART_INVALID', '파일 한 개가 필요합니다.');
const originalPath = resolve(this.originals, file!.storageKey);
await rename(tempPath, originalPath);
try {
db.transaction(() => {
if (g.kind === 'user') {
const session = g.sessionId
? 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', '계정 세션이 종료되었습니다.');
}
const row = this.request(g, requestId),
current = db.select().from(s.files).where(eq(s.files.id, file!.id)).get();
if (
row.status !== 'accepted' ||
this.blocked(g, row) ||
current?.uploadId !== uploadId ||
current.status !== 'uploading'
)
fail(409, 'UPLOAD_CANCELLED', '전송이 취소되었거나 권한이 변경되었습니다.');
const batch = db
.select()
.from(s.batches)
.where(eq(s.batches.requestId, requestId))
.get()!,
now = Date.now(),
itemId = randomUUID();
db.update(s.files)
.set({ status: 'stored', checksum: checksum.digest('hex'), updatedAt: now })
.where(eq(s.files.id, file!.id))
.run();
db.delete(s.reservations).where(eq(s.reservations.fileId, file!.id)).run();
db.insert(s.items)
.values({
id: itemId,
batchId: batch.id,
kind: 'file',
fileId: file!.id,
createdAt: now,
expiresAt: now + cfg.fileRetentionDays * 86400000,
})
.run();
this.service.accounts.createAccess(row, itemId);
const all = this.fileRows(requestId);
db.update(s.batches)
.set({
status: all.every((f) => f.status === 'stored') ? 'stored' : 'waiting',
storedAt: now,
})
.where(eq(s.batches.id, batch.id))
.run();
});
} catch (error) {
await unlink(originalPath).catch(() => {});
throw error;
}
return { uploadId: uploadId!, fileId: file!.id, status: 'stored' };
} catch (error) {
await unlink(tempPath).catch(() => {});
db.delete(s.reservations).where(eq(s.reservations.fileId, file!.id)).run();
db.update(s.files)
.set({ status: 'failed', updatedAt: Date.now() })
.where(and(eq(s.files.id, file!.id), eq(s.files.uploadId, uploadId!)))
.run();
db.update(s.batches)
.set({ status: 'waiting' })
.where(and(eq(s.batches.requestId, requestId), eq(s.batches.status, 'uploading')))
.run();
if (error instanceof ApiError) throw error;
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOSPC')
fail(507, 'DISK_FULL', '서버 저장 공간이 부족합니다.');
fail(400, 'UPLOAD_FAILED', '업로드가 중단되었습니다. 파일을 다시 선택해 재시도해 주세요.');
} finally {
this.active.delete(uploadId!);
this.service.notifyTransfer(requestId);
}
}
cancel(g: StorageSubject, id: string) {
const file = this.service.db
.select()
.from(s.files)
.where(
and(
eq(s.files.uploadId, id),
g.kind === 'user' ? eq(s.files.ownerUserId, g.id) : eq(s.files.ownerGuestId, g.id),
),
)
.get();
if (!file) fail(404, 'UPLOAD_NOT_FOUND', '업로드를 찾을 수 없습니다.');
if (file!.status !== 'uploading')
fail(409, 'UPLOAD_NOT_ACTIVE', '진행 중인 업로드만 취소할 수 있습니다.');
this.active.get(id)?.controller.abort();
this.service.db
.update(s.files)
.set({ status: 'failed', updatedAt: Date.now() })
.where(eq(s.files.id, file!.id))
.run();
}
download(g: StorageSubject, accessId: string) {
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) };
}
async preview(g: StorageSubject, id: string) {
const { path } = this.download(g, id);
try {
const handle = await open(path, 'r');
const magic = Buffer.alloc(16);
try {
await handle.read(magic, 0, 16, 0);
} finally {
await handle.close();
}
const raster =
magic.subarray(0, 3).equals(Buffer.from([255, 216, 255])) ||
magic.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
/^GIF8[79]a/.test(magic.toString('ascii')) ||
(magic.toString('ascii', 0, 4) === 'RIFF' && magic.toString('ascii', 8, 12) === 'WEBP') ||
(magic.toString('ascii', 4, 8) === 'ftyp' &&
['avif', 'avis', 'heic', 'heix', 'mif1'].includes(magic.toString('ascii', 8, 12)));
if (!raster) throw Error();
const image = sharp(path, { limitInputPixels: 40000000, animated: false });
const metadata = await image.metadata();
if (!['jpeg', 'png', 'webp', 'gif', 'avif', 'heif'].includes(metadata.format ?? ''))
throw Error();
return await image
.resize(720, 720, { fit: 'inside', withoutEnlargement: true })
.rotate()
.webp({ quality: 78 })
.toBuffer();
} catch {
fail(415, 'PREVIEW_UNSUPPORTED', '이 파일은 이미지 미리보기를 지원하지 않습니다.');
}
}
deleteAccess(g: StorageSubject, ids: string[]) {
const { db, cfg } = this.service;
return db.transaction(() => {
const unique = [...new Set(ids)],
rows = unique.map((id) => this.access(g, id));
const id = randomUUID(),
now = Date.now();
db.insert(s.deletions)
.values({
id,
guestId: g.kind === 'user' ? null : g.id,
userId: g.kind === 'user' ? g.id : null,
createdAt: now,
undoUntil: now + cfg.undoSeconds * 1000,
})
.run();
for (const row of rows) {
db.update(s.access).set({ deletedAt: now }).where(eq(s.access.id, row.access.id)).run();
db.insert(s.deletionTargets)
.values({ id: randomUUID(), operationId: id, accessId: row.access.id })
.run();
}
return { operationId: id, undoUntil: now + cfg.undoSeconds * 1000 };
});
}
undo(g: StorageSubject, id: string) {
const { db } = this.service;
return db.transaction(() => {
const operation = db
.select()
.from(s.deletions)
.where(
and(
eq(s.deletions.id, id),
g.kind === 'user' ? eq(s.deletions.userId, g.id) : eq(s.deletions.guestId, g.id),
),
)
.get();
if (!operation) fail(404, 'DELETE_NOT_FOUND', '삭제 작업을 찾을 수 없습니다.');
if (operation!.restoredAt) return { restored: true };
if (operation!.undoUntil <= Date.now())
fail(409, 'UNDO_EXPIRED', '실행 취소 시간이 지났습니다.');
const targets = db
.select()
.from(s.deletionTargets)
.where(eq(s.deletionTargets.operationId, id))
.all();
let count = 0;
for (const target of targets) {
const row = db
.select({ access: s.access, item: s.items })
.from(s.access)
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
.where(
and(
eq(s.access.id, target.accessId),
g.kind === 'user' ? eq(s.access.userId, g.id) : eq(s.access.guestId, g.id),
eq(s.access.deletedAt, operation!.createdAt),
gt(s.items.expiresAt, Date.now()),
),
)
.get();
if (row) {
db.update(s.access).set({ deletedAt: null }).where(eq(s.access.id, row.access.id)).run();
count++;
}
}
db.update(s.deletions).set({ restoredAt: Date.now() }).where(eq(s.deletions.id, id)).run();
return { restored: count > 0 };
});
}
cleanup(): Promise<void> {
if (this.cleanupPromise) return this.cleanupPromise;
this.cleanupPromise = this.performCleanup().finally(() => {
this.cleanupPromise = undefined;
});
return this.cleanupPromise;
}
private async performCleanup() {
if (this.cleaning) return;
this.cleaning = true;
try {
const { db, cfg } = this.service,
now = Date.now();
const expired = db
.select()
.from(s.reservations)
.where(sql`${s.reservations.expiresAt} <= ${now}`)
.all();
for (const r of expired) {
const file = db.select().from(s.files).where(eq(s.files.id, r.fileId)).get();
if (file) this.release(file.requestId);
}
for (const file of db.select().from(s.files).where(eq(s.files.status, 'uploading')).all()) {
if (!file.uploadId || !this.active.has(file.uploadId)) {
db.update(s.files)
.set({ status: 'failed', updatedAt: now })
.where(eq(s.files.id, file.id))
.run();
}
}
const all = db
.select()
.from(s.files)
.where(inArray(s.files.status, ['stored', 'cleaning']))
.all();
for (const file of all) {
const refs = db
.select({ id: s.items.id })
.from(s.items)
.innerJoin(s.access, eq(s.access.itemId, s.items.id))
.where(
and(
eq(s.items.fileId, file.id),
gt(s.items.expiresAt, now),
orLive(now, cfg.undoSeconds),
),
)
.all();
if (refs.length) continue;
db.update(s.files).set({ status: 'cleaning' }).where(eq(s.files.id, file.id)).run();
try {
await unlink(resolve(this.originals, file.storageKey));
} catch (error) {
if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT')
continue;
}
db.update(s.files)
.set({ status: 'failed', checksum: null, updatedAt: now })
.where(eq(s.files.id, file.id))
.run();
}
const validKeys = new Set(
db
.select()
.from(s.files)
.where(inArray(s.files.status, ['stored', 'cleaning']))
.all()
.map((f) => f.storageKey),
);
for (const name of await readdir(this.originals)) {
if (validKeys.has(name)) continue;
const path = resolve(this.originals, name),
metadata = await stat(path);
if (metadata.mtimeMs < now - cfg.orphanHours * 3600000) await unlink(path).catch(() => {});
}
for (const name of await readdir(this.temp)) {
if (this.active.has(name)) continue;
const path = resolve(this.temp, name);
const metadata = await stat(path);
if (metadata.mtimeMs < now - cfg.orphanHours * 3600000) await unlink(path).catch(() => {});
}
} finally {
this.cleaning = false;
}
}
abortSession(id: string) {
for (const upload of this.active.values())
if (upload.sessionId === id) upload.controller.abort();
}
abortUser(id: string) {
for (const upload of this.active.values())
if (upload.guestId === `user:${id}`) upload.controller.abort();
}
close() {
for (const upload of this.active.values()) upload.controller.abort();
}
}
function orLive(now: number, undoSeconds: number) {
return sql`(${s.access.deletedAt} IS NULL OR ${s.access.deletedAt} > ${now - undoSeconds * 1000})`;
}

141
apps/server/src/vault.js Normal file
View File

@@ -0,0 +1,141 @@
import { randomUUID } from 'node:crypto';
import { and, eq } from 'drizzle-orm';
import * as s from './db/schema.js';
import { fail } from './security.js';
export class Vault {
constructor(service) {
this.service = service;
}
bindReceipt(requestId, current) {
if (!current || !this.service.auth.serialize(current.user).verified) return;
this.service.db
.insert(s.receiptAccounts)
.values({ requestId, userId: current.user.id, sessionId: current.session.id })
.onConflictDoNothing()
.run();
}
receipt(request, itemId) {
const { db, auth } = this.service;
const binding = db
.select()
.from(s.receiptAccounts)
.where(eq(s.receiptAccounts.requestId, request.id))
.get();
if (!binding) return;
const session = db
.select()
.from(s.accountSessions)
.where(eq(s.accountSessions.id, binding.sessionId))
.get();
if (
!session ||
session.revokedAt ||
session.expiresAt <= Date.now() ||
session.userId !== binding.userId
)
return;
if (!auth.serialize(auth.user(binding.userId)).verified) return;
db.insert(s.access)
.values({ id: randomUUID(), itemId, userId: binding.userId, role: 'recipient', read: false })
.onConflictDoNothing()
.run();
}
source(requestId) {
return this.service.db
.select()
.from(s.vaultTransfers)
.where(eq(s.vaultTransfers.requestId, requestId))
.get();
}
authorize(source) {
const { db, accounts } = this.service;
const session = db
.select()
.from(s.accountSessions)
.where(eq(s.accountSessions.id, source.sessionId))
.get();
if (
!session ||
session.userId !== source.userId ||
session.revokedAt ||
session.expiresAt <= Date.now()
)
fail(401, 'SESSION_REVOKED', '보낸 계정의 세션이 종료되었습니다.');
const row = accounts.access(source.userId, source.accessId);
if (!['self', 'recipient'].includes(row.access.role))
fail(403, 'VAULT_REQUIRED', '보관함에 있는 자료만 보낼 수 있습니다.');
if (row.item.fileId) {
const file = db.select().from(s.files).where(eq(s.files.id, row.item.fileId)).get();
if (!file || file.status !== 'stored')
fail(404, 'FILE_NOT_FOUND', '파일이 없거나 만료되었습니다.');
return { ...row, file };
}
return row;
}
forward(guest, current, accessId, targetDeviceId, ip) {
const source = { userId: current.user.id, sessionId: current.session.id, accessId };
const row = this.authorize(source);
const input = row.item.url
? { targetDeviceId, kind: 'url', byteSize: Buffer.byteLength(row.item.url), count: 1 }
: {
targetDeviceId,
kind: 'file',
byteSize: row.file.size,
count: 1,
files: [
{ id: row.file.id, name: row.file.name, size: row.file.size, type: row.file.type },
],
};
const result = this.service.createRequest(guest, input, ip, true);
this.service.db
.insert(s.vaultTransfers)
.values({ ...source, requestId: result.requestId })
.run();
return result;
}
deliver(requestId) {
const source = this.source(requestId);
if (!source) return;
const { db, accounts } = this.service;
const row = this.authorize(source);
const request = db.select().from(s.requests).where(eq(s.requests.id, requestId)).get();
const batch = db.select().from(s.batches).where(eq(s.batches.requestId, requestId)).get();
if (batch.status === 'stored') return;
if (
request.status !== 'accepted' ||
request.startBy <= Date.now() ||
this.service.isBlocked(request.senderGuestId, request.recipientGuestId)
)
fail(409, 'REQUEST_STATE', '유효한 수락이 필요합니다.');
const now = Date.now(),
itemId = randomUUID();
db.insert(s.items)
.values({
id: itemId,
batchId: batch.id,
kind: row.item.kind,
url: row.item.url,
fileId: row.item.fileId,
createdAt: now,
expiresAt: now + 7 * 86400000,
})
.run();
accounts.createAccess(request, itemId);
db.update(s.batches)
.set({ status: 'stored', storedAt: now })
.where(eq(s.batches.id, batch.id))
.run();
}
note(userId, accessId, note) {
const row = this.service.accounts.access(userId, accessId);
if (row.access.role !== 'self')
fail(403, 'VAULT_REQUIRED', '임시 보관함 자료만 수정할 수 있습니다.');
this.service.db
.update(s.access)
.set({ note: note || null })
.where(and(eq(s.access.id, accessId), eq(s.access.userId, userId)))
.run();
this.service.accounts.notify(userId);
}
}

149
apps/server/test/helpers.ts Normal file
View File

@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { io, type Socket } from 'socket.io-client';
import { eq } from 'drizzle-orm';
import { createApp } from '../src/app.js';
import { loadConfig } from '../src/config.js';
import * as schema from '../src/db/schema.js';
import { clientIp } from '../src/security.js';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export function urlFor(value: string) {
return /^https?:\/\//.test(value)
? value
.split('\n')
.map((url) => new URL(url).href)
.join('\n')
: `https://example.test/${encodeURIComponent(value)}`;
}
const origin = 'http://hop.test';
type Client = { cookie: string; csrf: string; deviceId: string; socket?: Socket; ip: string };
export async function fixture(discovery = false, mailer?: import('../src/auth.js').Mailer) {
const folder = await mkdtemp(join(tmpdir(), 'hop-api-'));
const cfg = {
...loadConfig({
APP_URL: origin,
DATABASE_PATH: ':memory:',
SESSION_SECRET: 's'.repeat(40),
DISCOVERY_SECRET: 'd'.repeat(40),
}),
discoveryEnabled: discovery,
uploadDir: join(folder, 'uploads'),
minFreeBytes: 0,
trustedProxies: ['127.0.0.1', '::1'],
offlineGraceMs: 20,
};
const runtime = await createApp(cfg, { mailer });
await runtime.app.listen({ port: 0, host: '127.0.0.1' });
const address = runtime.app.server.address();
assert(address && typeof address !== 'string');
const url = `http://127.0.0.1:${address.port}`;
const sockets: Socket[] = [];
const call = async (
c: Client | null,
path: string,
method = 'GET',
payload?: unknown,
key?: string,
headers: Record<string, string> = {},
) =>
runtime.app.inject({
method: method as any,
url: `/api${path}`,
headers: {
origin,
...(c ? { cookie: c.cookie, 'x-csrf-token': c.csrf, 'x-forwarded-for': c.ip } : {}),
...(key ? { 'idempotency-key': key } : {}),
...headers,
},
payload,
});
const create = async (ip = '203.0.113.1', online = true) => {
const response = await call(null, '/guest/session', 'POST', {});
assert.equal(response.statusCode, 200);
const result = response.json();
const c: Client = {
cookie: String(response.headers['set-cookie']).split(';')[0]!,
csrf: result.csrfToken,
deviceId: result.device.id,
ip,
};
if (online) await connect(c);
return c;
};
const connect = async (c: Client) => {
const socket = io(url, {
transports: ['websocket'],
reconnection: false,
auth: { csrfToken: c.csrf },
extraHeaders: { Origin: origin, Cookie: c.cookie, 'X-Forwarded-For': c.ip },
});
sockets.push(socket);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(Error('socket connect timeout')), 3000);
socket.once('connect', () => {
clearTimeout(timer);
resolve();
});
socket.once('connect_error', (err) => {
clearTimeout(timer);
reject(err);
});
});
c.socket = socket;
return socket;
};
const invitations = new Map<string, any>();
const space = async (a: Client, b: Client, approvalRequired = false) => {
const current = (await call(a, '/spaces')).json().spaces;
let result = invitations.get(a.deviceId);
if (!result || !current.some((room: any) => room.id === result.space.id)) {
const response = await call(a, '/spaces', 'POST', { approvalRequired });
assert.equal(response.statusCode, 200, response.body);
result = response.json();
invitations.set(a.deviceId, result);
}
const join = await call(b, '/spaces/join', 'POST', { code: result.code });
assert.equal(join.statusCode, 200);
return result;
};
const request = async (
a: Client,
b: Client,
text = '안녕하세요',
kind: 'url' = 'url',
count = 1,
key = randomUUID(),
) => {
const r = await call(
a,
'/transfer-requests',
'POST',
{ targetDeviceId: b.deviceId, kind, byteSize: Buffer.byteLength(urlFor(text)), count },
key,
);
return r;
};
const complete = async (a: Client, b: Client, text = '안녕하세요') => {
const r = await request(a, b, text);
assert.equal(r.statusCode, 200);
const id = r.json().requestId;
assert.equal(
(await call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
assert.equal(
(await call(a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor(text)] }, randomUUID()))
.statusCode,
200,
);
return id;
};
const close = async () => {
for (const socket of sockets) socket.disconnect();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
};
return { ...runtime, cfg, url, call, create, connect, space, request, complete, close };
}

View File

@@ -0,0 +1,66 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createResendMailer } from '../src/mail.js';
import { ApiError } from '../src/security.js';
test('Resend 키가 없으면 발송 모듈은 비활성', () => {
assert.equal(createResendMailer({ mailFrom: 'hop. <hello@noname.company>' }), undefined);
});
test('Resend 발송 계약과 고정 중복 키: 키는 서버 Authorization에만 전달', async () => {
const calls: { url: unknown; options: RequestInit | undefined }[] = [];
const fakeFetch = (async (url: unknown, options: RequestInit) => {
calls.push({ url, options });
return new Response(JSON.stringify({ id: 'test-message' }), { status: 200 });
}) as typeof fetch;
const mailer = createResendMailer(
{ resendApiKey: 're_test_fixture', mailFrom: 'hop. <hello@noname.company>' },
fakeFetch,
)!;
await mailer({
to: 'recipient@example.com',
subject: '인증',
text: 'test body',
idempotencyKey: 'auth-verify-fixture',
});
assert.equal(calls.length, 1);
assert.equal(calls[0]!.url, 'https://api.resend.com/emails');
const options = calls[0]!.options!,
headers = new Headers(options.headers);
assert.equal(headers.get('Authorization'), 'Bearer re_test_fixture');
assert.equal(headers.get('Idempotency-Key'), 'auth-verify-fixture');
assert.equal(options.redirect, 'error');
assert(options.signal instanceof AbortSignal);
assert.deepEqual(JSON.parse(String(options.body)), {
from: 'hop. <hello@noname.company>',
to: ['recipient@example.com'],
subject: '인증',
text: 'test body',
});
assert(!String(options.body).includes('re_test_fixture'));
});
test('Resend 거절·타임아웃·잘못된 응답은 민감한 원문 없이 오류를 반환', async () => {
for (const fakeFetch of [
async () =>
new Response(JSON.stringify({ message: 'private provider response re_test_fixture' }), {
status: 401,
}),
async () => {
throw Error('private credential re_test_fixture');
},
async () => new Response('{}', { status: 200 }),
]) {
const mailer = createResendMailer(
{ resendApiKey: 're_test_fixture', mailFrom: 'hop. <hello@noname.company>' },
fakeFetch as typeof fetch,
)!;
await assert.rejects(
mailer({ to: 'recipient@example.com', subject: '인증', text: 'test body' }),
(error) =>
error instanceof ApiError &&
error.code === 'MAIL_DELIVERY_FAILED' &&
error.status === 503 &&
!error.message.includes('re_test_fixture') &&
!error.message.includes('private'),
);
}
});

View File

@@ -0,0 +1,74 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import Database from 'better-sqlite3';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import { openDatabase } from '../src/db/index.js';
test('사용 중 적용된 초기 계정 DB 버전을 후속 마이그레이션으로 보정하고 기록 보존', async () => {
const folder = await mkdtemp(join(tmpdir(), 'hop-legacy-'));
try {
const path = join(folder, 'legacy.sqlite'),
old = new Database(path),
migrations = readMigrationFiles({
migrationsFolder: join(import.meta.dirname, '../drizzle'),
});
old.exec(
'CREATE TABLE __drizzle_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT,hash TEXT NOT NULL,created_at INTEGER)',
);
for (const [index, migration] of migrations.slice(0, 5).entries()) {
for (let statement of migration.sql) {
if (index === 4 && statement.includes('CREATE TABLE `__new_deletion_operations`'))
statement = statement.replace('`guestId` text,', '`guestId` text NOT NULL,');
old.exec(statement);
}
old
.prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)')
.run(
index === 4
? 'fb1af44cf19e4f77c50f07608ba046819dce40cae3925e0b632d1d9afa045068'
: migration.hash,
index === 4 ? 1791448938074 : migration.folderMillis,
);
}
const now = Date.now();
old
.prepare('INSERT INTO guest_sessions(id,tokenHash,expiresAt,createdAt) VALUES (?,?,?,?)')
.run('guest', 'hash', now + 86400000, now);
old
.prepare('INSERT INTO deletion_operations(id,guestId,createdAt,undoUntil) VALUES (?,?,?,?)')
.run('operation', 'guest', now, now + 5000);
old.close();
const { sqlite } = openDatabase(path);
const info = sqlite.pragma('table_info(deletion_operations)') as {
name: string;
notnull: number;
}[];
assert.equal(info.find((c) => c.name === 'guestId')!.notnull, 0);
assert.equal((sqlite.prepare('SELECT count(*) AS n FROM guest_sessions').get() as any).n, 1);
assert.equal(
(sqlite.prepare('SELECT guestId FROM deletion_operations WHERE id=?').get('operation') as any)
.guestId,
'guest',
);
assert.equal((sqlite.pragma('foreign_key_check') as any[]).length, 0);
assert.equal(
(
sqlite
.prepare('SELECT count(*) AS n FROM __drizzle_migrations WHERE hash=?')
.get('fb1af44cf19e4f77c50f07608ba046819dce40cae3925e0b632d1d9afa045068') as any
).n,
1,
);
sqlite.close();
const again = openDatabase(path);
assert.equal(
(again.sqlite.prepare('SELECT count(*) AS n FROM guest_sessions').get() as any).n,
1,
);
again.sqlite.close();
} finally {
await rm(folder, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,557 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { io, type Socket } from 'socket.io-client';
import { eq } from 'drizzle-orm';
import { createApp } from '../src/app.js';
import { loadConfig } from '../src/config.js';
import * as schema from '../src/db/schema.js';
import { clientIp } from '../src/security.js';
import { fixture, urlFor } from './helpers.js';
test('익명 이름과 비밀 쿠키가 유지되고 공개 ID는 인증이 아니다', async () => {
const f = await fixture();
try {
const a = await f.create();
const changed = await f.call(a, '/device/me', 'PATCH', { name: '나의 Mac' });
assert.equal(changed.statusCode, 200);
const again = await f.call(a, '/guest/session', 'POST', {});
assert.equal(again.json().device.name, '나의 Mac');
assert.equal(again.json().device.id, a.deviceId);
assert.equal((await f.call(null, '/device/me')).statusCode, 401);
assert.equal(
(await f.call({ ...a, cookie: `hop_guest=${a.deviceId}` }, '/device/me')).statusCode,
401,
);
assert.match(String(again.headers['set-cookie']), /HttpOnly/);
assert.match(String(again.headers['set-cookie']), /SameSite=Lax/);
} finally {
await f.close();
}
});
test('Origin, CSRF, HTML 이름 검증', async () => {
const f = await fixture();
try {
const a = await f.create();
assert.equal((await f.call(a, '/device/me', 'PATCH', { name: '<script>' })).statusCode, 400);
assert.equal(
(
await f.call(a, '/device/me', 'PATCH', { name: '테스트' }, undefined, {
origin: 'https://evil.test',
})
).statusCode,
403,
);
assert.equal(
(
await f.call(a, '/device/me', 'PATCH', { name: '테스트' }, undefined, {
'x-csrf-token': 'bad',
})
).statusCode,
403,
);
} finally {
await f.close();
}
});
test('검증 전 네트워크 탐색은 꺼지고 QR 코드로 다른 IP 연결', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create('198.51.100.2'),
c = await f.create('198.51.100.3');
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal((await f.request(a, b)).statusCode, 403);
await f.space(a, b);
const peers = (await f.call(a, '/discovery/devices')).json().devices;
assert.deepEqual(
peers.map((d: any) => d.id),
[b.deviceId],
);
assert.deepEqual(peers[0].sources, ['space']);
assert.equal((await f.request(a, c)).statusCode, 403);
} finally {
await f.close();
}
});
test('같은 공인 IP만 발견하고 다른 IP 기기에는 전송 금지', async () => {
const f = await fixture(true);
try {
const a = await f.create('8.8.8.8'),
b = await f.create('8.8.8.8'),
c = await f.create('1.1.1.1');
assert.deepEqual(
(await f.call(a, '/discovery/devices')).json().devices.map((d: any) => d.id),
[b.deviceId],
);
assert.equal((await f.request(a, c)).statusCode, 403);
} finally {
await f.close();
}
});
test('발견 끄기는 즉시 목록과 새 요청에서 제외', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
await f.call(b, '/device/me', 'PATCH', { discoverable: false });
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal((await f.request(a, b)).statusCode, 403);
} finally {
await f.close();
}
});
test('승인 전 참여자는 발견되지 않으며 생성자만 승인 가능', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
const invite = await f.space(a, b, true);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal(
(await f.call(c, `/spaces/${invite.space.id}/members/${b.deviceId}/approve`, 'POST', {}))
.statusCode,
404,
);
assert.equal(
(await f.call(a, `/spaces/${invite.space.id}/members/${b.deviceId}/approve`, 'POST', {}))
.statusCode,
200,
);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1);
} finally {
await f.close();
}
});
test('공간 퇴장, 재가입 거절, 폐쇄, QR 토큰 참여', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
const invite = (await f.call(a, '/spaces', 'POST', {})).json();
assert.equal(
(await f.call(b, '/spaces/join', 'POST', { token: invite.token })).statusCode,
200,
);
assert.equal(
(await f.call(a, `/spaces/${invite.space.id}/members/${b.deviceId}`, 'DELETE')).statusCode,
200,
);
assert.equal((await f.request(a, b)).statusCode, 403);
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: invite.code })).statusCode, 403);
assert.equal((await f.call(a, `/spaces/${invite.space.id}`, 'DELETE')).statusCode, 200);
assert.equal(
(await f.call(b, '/spaces/join', 'POST', { token: invite.token })).statusCode,
404,
);
} finally {
await f.close();
}
});
test('메타데이터만 수락 전 공개하고 전송 확정 금지', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const response = await f.request(a, b, '비밀 본문');
assert.equal(response.statusCode, 200);
const id = response.json().requestId;
assert.equal(
JSON.stringify((await f.call(b, '/transfer-requests')).json()).includes('비밀 본문'),
false,
);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('비밀 본문')] },
randomUUID(),
)
).statusCode,
409,
);
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
} finally {
await f.close();
}
});
test('수락 이후 실제 URL 저장, 수신 접근권 격리, 읽음 공유', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
await f.complete(a, b, '안녕\n<script>alert(1)</script>');
const received = (await f.call(b, '/device/items')).json().items[0];
assert.equal(received.url, urlFor('안녕\n<script>alert(1)</script>'));
assert.equal(received.read, false);
assert.equal(
(await f.call(c, `/access/${received.accessId}/read`, 'PATCH', { read: true })).statusCode,
404,
);
await f.call(b, `/access/${received.accessId}/read`, 'PATCH', { read: true });
assert.equal((await f.call(a, '/device/items')).json().items[0].recipientRead, true);
await f.call(b, `/access/${received.accessId}/read`, 'PATCH', { read: false });
assert.equal((await f.call(a, '/device/items')).json().items[0].recipientRead, false);
} finally {
await f.close();
}
});
test('다중 URL을 개별 항목으로 저장하고 내부 URL 전달을 허용', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const urls = ['https://example.com/', 'http://192.168.1.10/'];
const response = await f.request(a, b, urls.join('\n'), 'url', 2);
const id = response.json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
const result = await f.call(a, `/transfers/${id}/complete`, 'POST', { urls }, randomUUID());
assert.equal(result.statusCode, 200);
assert.equal((await f.call(b, '/device/items')).json().items.length, 2);
} finally {
await f.close();
}
});
test('URL 스크립트 프로토콜 및 승인 메타데이터 변경 차단', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const r = await f.request(a, b, 'https://example.com/', 'url', 1);
const id = r.json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: ['javascript:alert(1)'] },
randomUUID(),
)
).statusCode,
400,
);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: ['https://example.com/longer'] },
randomUUID(),
)
).statusCode,
409,
);
} finally {
await f.close();
}
});
test('동일 키 요청과 확정은 한 번만 처리하고 다른 내용은 충돌', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const key = randomUUID(),
text = 'repeat';
const one = await f.request(a, b, text, 'url', 1, key),
two = await f.request(a, b, text, 'url', 1, key);
assert.equal(one.json().requestId, two.json().requestId);
assert.equal((await f.request(a, b, 'different', 'url', 1, key)).statusCode, 409);
const id = one.json().requestId,
acceptKey = randomUUID(),
completeKey = randomUUID();
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, acceptKey)).statusCode,
200,
);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, acceptKey)).statusCode,
200,
);
await f.call(a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor(text)] }, completeKey);
await f.call(a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor(text)] }, completeKey);
assert.equal((await f.call(b, '/device/items')).json().items.length, 1);
} finally {
await f.close();
}
});
test('수신자만 수락하고 송신자만 확정 가능', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
const id = (await f.request(a, b)).json().requestId;
assert.equal(
(await f.call(a, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
403,
);
assert.equal(
(await f.call(c, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
404,
);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal(
(
await f.call(
b,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('안녕하세요')] },
randomUUID(),
)
).statusCode,
403,
);
} finally {
await f.close();
}
});
test('거절, 만료, 취소 후 확정 금지와 거절 재요청 제한', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = (await f.request(a, b)).json().requestId;
await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {});
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('안녕하세요')] },
randomUUID(),
)
).statusCode,
409,
);
assert.equal((await f.request(a, b)).statusCode, 429);
const c = await f.create();
await f.space(a, c);
const second = (await f.request(a, c)).json().requestId;
f.service.db
.update(schema.requests)
.set({ expiresAt: Date.now() - 1 })
.where(eq(schema.requests.id, second))
.run();
assert.equal(
(await f.call(c, `/transfer-requests/${second}/accept`, 'POST', {}, randomUUID())).statusCode,
409,
);
f.service.cleanup();
assert.equal(
f.service.db.select().from(schema.requests).where(eq(schema.requests.id, second)).get()!
.status,
'expired',
);
} finally {
await f.close();
}
});
test('수락 직전 오프라인 검사 및 여러 탭 합산', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const first = b.socket!;
const second = await f.connect(b);
first.disconnect();
await new Promise((r) => setTimeout(r, 35));
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1);
const id = (await f.request(a, b)).json().requestId;
second.disconnect();
await new Promise((r) => setTimeout(r, 35));
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
409,
);
} finally {
await f.close();
}
});
test('방 종료 시 진행 중 수락 전송도 취소되며 재연결로 확정할 수 없음', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
const invite = await f.space(a, b);
const id = (await f.request(a, b)).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
await f.call(a, `/spaces/${invite.space.id}`, 'DELETE');
b.socket!.disconnect();
await new Promise((r) => setTimeout(r, 35));
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('안녕하세요')] },
randomUUID(),
)
).statusCode,
409,
);
await f.connect(b);
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
} finally {
await f.close();
}
});
test('익명 차단은 새로운 요청과 수락한 요청의 확정을 금지', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = (await f.request(a, b)).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal((await f.call(b, `/transfer-requests/${id}/block`, 'POST', {})).statusCode, 200);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('안녕하세요')] },
randomUUID(),
)
).statusCode,
409,
);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
} finally {
await f.close();
}
});
test('익명 초기화는 이전 접근권만 폐기하고 상대 자료는 유지', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
await f.complete(a, b);
const response = await f.call(b, '/device/reset', 'POST', {});
assert.equal(response.statusCode, 200);
assert.equal((await f.call(b, '/device/items')).statusCode, 401);
const next = response.json();
const fresh = {
...b,
cookie: String(response.headers['set-cookie']).split(';')[0]!,
csrf: next.csrfToken,
deviceId: next.device.id,
};
assert.equal((await f.call(fresh, '/device/items')).json().items.length, 0);
assert.equal((await f.call(a, '/device/items')).json().items.length, 1);
} finally {
await f.close();
}
});
test('만료 자료는 조회 불가하고 본문 정리', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
await f.complete(a, b);
f.service.db
.update(schema.items)
.set({ expiresAt: Date.now() - 1 })
.run();
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
f.service.cleanup();
assert.equal(f.service.db.select().from(schema.items).all().length, 0);
} finally {
await f.close();
}
});
test('코드 대입 횟수 제한과 공간 만료 적용', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
const invite = (await f.call(a, '/spaces', 'POST', {})).json();
f.service.db
.update(schema.spaces)
.set({ expiresAt: Date.now() - 1 })
.run();
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: invite.code })).statusCode, 404);
for (let i = 0; i < 10; i++) await f.call(b, '/spaces/join', 'POST', { code: 'ABCDEFGH' });
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: 'ABCDEFGH' })).statusCode, 429);
} finally {
await f.close();
}
});
test('임의 X-Forwarded-For는 신뢰하지 않고 명시된 프록시 체인만 해석', () => {
assert.equal(clientIp('8.8.8.8', { 'x-forwarded-for': '1.1.1.1' }, []), '8.8.8.8');
assert.equal(clientIp('127.0.0.1', { 'x-forwarded-for': '1.1.1.1' }, ['127.0.0.1']), '1.1.1.1');
assert.equal(clientIp('127.0.0.1', {}, ['127.0.0.1']), null);
assert.equal(clientIp('127.0.0.1', { 'x-forwarded-for': 'bogus' }, ['127.0.0.1']), null);
assert.equal(
clientIp('127.0.0.1', { 'x-forwarded-for': '8.8.8.8, 1.1.1.1' }, ['127.0.0.1']),
'1.1.1.1',
);
});
test('신규 일반 텍스트 요청과 본문 확정을 거절하고 URL·파일만 지원', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
assert.equal(
(
await f.call(
a,
'/transfer-requests',
'POST',
{ targetDeviceId: b.deviceId, kind: 'text', byteSize: 5, count: 1 },
randomUUID(),
)
).statusCode,
400,
);
const id = (await f.request(a, b, 'https://example.com/')).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal(
(await f.call(a, `/transfers/${id}/complete`, 'POST', { text: 'hello' }, randomUUID()))
.statusCode,
400,
);
assert.equal((await f.call(b, '/device/snapshot')).json().items.length, 0);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: ['https://example.com/'] },
randomUUID(),
)
).statusCode,
200,
);
const snapshot = (await f.call(b, '/device/snapshot')).json();
assert.equal(snapshot.items[0].kind, 'url');
assert.equal(snapshot.items[0].url, 'https://example.com/');
assert.equal('text' in snapshot.items[0], false);
} finally {
await f.close();
}
});

View File

@@ -0,0 +1,411 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { readFile, mkdtemp, rm, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import Database from 'better-sqlite3';
import sharp from 'sharp';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
import { openDatabase } from '../src/db/index.js';
async function fileRequest(
f: Awaited<ReturnType<typeof fixture>>,
a: any,
b: any,
files: { name: string; size: number; type: string }[],
) {
const r = await f.call(
a,
'/transfer-requests',
'POST',
{
targetDeviceId: b.deviceId,
kind: 'file',
byteSize: files.reduce((sum, v) => sum + v.size, 0),
count: files.length,
files,
},
randomUUID(),
);
assert.equal(r.statusCode, 200, r.body);
return r.json().requestId as string;
}
async function upload(
f: Awaited<ReturnType<typeof fixture>>,
a: any,
id: string,
name: string,
bytes: Buffer,
index = 0,
key = randomUUID(),
) {
const boundary = `hop${randomUUID()}`;
const body = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: application/octet-stream\r\n\r\n`,
),
bytes,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
return f.call(a, `/uploads?requestId=${id}&fileIndex=${index}`, 'POST', body, key, {
'content-type': `multipart/form-data; boundary=${boundary}`,
});
}
test('파일은 수락 전 업로드되지 않으며 실제 스트리밍 저장과 인증 다운로드', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
const bytes = Buffer.from('실제 파일 내용'),
id = await fileRequest(f, a, b, [
{ name: '파일.txt', size: bytes.length, type: 'text/plain' },
]);
assert.equal((await upload(f, a, id, '파일.txt', bytes)).statusCode, 409);
assert.equal((await readdir(f.service.storage.originals)).length, 0);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
const uploaded = await upload(f, a, id, '파일.txt', bytes);
assert.equal(uploaded.statusCode, 200, uploaded.body);
const item = (await f.call(b, '/device/items')).json().items[0];
assert.equal(item.file.name, '파일.txt');
assert.equal((await f.call(c, `/access/${item.accessId}/download`)).statusCode, 404);
const download = await f.call(b, `/access/${item.accessId}/download`);
assert.equal(download.statusCode, 200);
assert.deepEqual(download.rawPayload, bytes);
assert.match(String(download.headers['content-disposition']), /attachment/);
assert.equal(download.headers['x-content-type-options'], 'nosniff');
assert.equal(f.service.db.select().from(s.reservations).all().length, 0);
} finally {
await f.close();
}
});
test('선언 크기와 다른 파일, 요청 밖 파일, 이름 변경을 거절하고 임시 파일 회수', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [{ name: 'a.txt', size: 4, type: 'text/plain' }]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('excess'))).statusCode, 413);
assert.equal((await readdir(f.service.storage.temp)).length, 0);
assert.equal((await upload(f, a, id, 'changed.txt', Buffer.from('test'))).statusCode, 409);
assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('test'), 1)).statusCode, 400);
assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('test'))).statusCode, 200);
} finally {
await f.close();
}
});
test('부분 성공은 유지하고 실패 파일만 재시도', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [
{ name: 'one.txt', size: 3, type: 'text/plain' },
{ name: 'two.txt', size: 3, type: 'text/plain' },
]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal((await upload(f, a, id, 'one.txt', Buffer.from('one'))).statusCode, 200);
assert.equal((await upload(f, a, id, 'two.txt', Buffer.from('badsize'), 1)).statusCode, 413);
assert.equal((await f.call(b, '/device/items')).json().items.length, 1);
assert.equal((await upload(f, a, id, 'two.txt', Buffer.from('two'), 1)).statusCode, 200);
assert.equal((await f.call(b, '/device/items')).json().items.length, 2);
assert.equal(
f.service.storage.fileRows(id).every((v) => v.status === 'stored'),
true,
);
} finally {
await f.close();
}
});
test('예약을 원자적으로 적용해 전체 익명 상한을 우회하지 못한다', async () => {
const f = await fixture();
try {
f.cfg.totalGuestQuota = 4;
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
await f.space(a, c);
const one = await fileRequest(f, a, b, [
{ name: 'one', size: 4, type: 'application/octet-stream' },
]),
two = await fileRequest(f, c, b, [
{ name: 'two', size: 4, type: 'application/octet-stream' },
]);
const results = await Promise.all([
f.call(b, `/transfer-requests/${one}/accept`, 'POST', {}, randomUUID()),
f.call(b, `/transfer-requests/${two}/accept`, 'POST', {}, randomUUID()),
]);
assert.deepEqual(results.map((r) => r.statusCode).sort(), [200, 507]);
assert.equal(
f.service.db
.select()
.from(s.reservations)
.all()
.reduce((sum, r) => sum + r.bytes, 0),
4,
);
} finally {
await f.close();
}
});
test('개별 익명 용량, 파일당 제한과 취소 후 예약 회수', async () => {
const f = await fixture();
try {
f.cfg.guestQuota = 3;
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [
{ name: 'large', size: 4, type: 'application/octet-stream' },
]);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
413,
);
f.cfg.guestQuota = 4;
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
await f.call(a, `/transfer-requests/${id}/cancel`, 'POST', {});
assert.equal(f.service.db.select().from(s.reservations).all().length, 0);
assert.equal((await upload(f, a, id, 'large', Buffer.from('test'))).statusCode, 409);
} finally {
await f.close();
}
});
test('인증 래스터 미리보기는 읽음이 아니며 SVG와 실행 콘텐츠는 거절', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const png = await sharp({
create: { width: 16, height: 16, channels: 3, background: '#365e40' },
})
.png()
.toBuffer();
const id = await fileRequest(f, a, b, [
{ name: 'test.png', size: png.length, type: 'image/png' },
]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
await upload(f, a, id, 'test.png', png);
const item = (await f.call(b, '/device/items')).json().items[0];
const preview = await f.call(b, `/access/${item.accessId}/preview`);
assert.equal(preview.statusCode, 200);
assert.equal(preview.headers['content-type'], 'image/webp');
assert.equal((await f.call(b, '/device/items')).json().items[0].read, false);
const c = await f.create();
await f.space(a, c);
const svg = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
);
const svgId = await fileRequest(f, c, b, [
{ name: 'evil.svg', size: svg.length, type: 'image/svg+xml' },
]);
await f.call(b, `/transfer-requests/${svgId}/accept`, 'POST', {}, randomUUID());
await upload(f, c, svgId, 'evil.svg', svg);
const svgItem = (await f.call(b, '/device/items'))
.json()
.items.find((v: any) => v.file.name === 'evil.svg');
assert.equal((await f.call(b, `/access/${svgItem.accessId}/preview`)).statusCode, 415);
} finally {
await f.close();
}
});
test('내 목록 삭제는 상대 접근권을 유지하고 서버 복원 ID로 실행 취소', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
await f.complete(a, b);
const item = (await f.call(b, '/device/items')).json().items[0];
const deletion = (
await f.call(b, '/access/delete-batch', 'POST', { ids: [item.accessId] })
).json();
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
assert.equal((await f.call(a, '/device/items')).json().items.length, 1);
assert.equal(
(await f.call(c, `/deletion-operations/${deletion.operationId}/undo`, 'POST', {})).statusCode,
404,
);
assert.equal(
(await f.call(b, `/deletion-operations/${deletion.operationId}/undo`, 'POST', {})).statusCode,
200,
);
assert.equal((await f.call(b, '/device/items')).json().items.length, 1);
} finally {
await f.close();
}
});
test('실행 취소 기한과 자연 만료를 검사하며 마지막 권한 뒤 파일 정리', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [{ name: 'delete.txt', size: 4, type: 'text/plain' }]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
await upload(f, a, id, 'delete.txt', Buffer.from('test'));
const sender = (await f.call(a, '/device/items')).json().items[0],
receiver = (await f.call(b, '/device/items')).json().items[0];
await f.call(a, '/access/delete-batch', 'POST', { ids: [sender.accessId] });
const d = (
await f.call(b, '/access/delete-batch', 'POST', { ids: [receiver.accessId] })
).json();
await f.service.storage.cleanup();
assert.equal((await readdir(f.service.storage.originals)).length, 1);
f.service.db
.update(s.deletions)
.set({ undoUntil: Date.now() - 1 })
.where(eq(s.deletions.id, d.operationId))
.run();
assert.equal(
(await f.call(b, `/deletion-operations/${d.operationId}/undo`, 'POST', {})).statusCode,
409,
);
f.service.db
.update(s.access)
.set({ deletedAt: Date.now() - 6000 })
.run();
f.service.cleanup();
await f.service.storage.cleanup();
assert.equal((await readdir(f.service.storage.originals)).length, 0);
} finally {
await f.close();
}
});
test('Phase 1 자료가 있는 DB를 Phase 2로 마이그레이션해도 본문과 접근권 유지', async () => {
const folder = await mkdtemp(join(tmpdir(), 'hop-migrate-'));
try {
const path = join(folder, 'old.sqlite'),
old = new Database(path);
const migrations = readMigrationFiles({
migrationsFolder: join(import.meta.dirname, '../drizzle'),
});
old.exec(
'CREATE TABLE __drizzle_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT,hash TEXT NOT NULL,created_at INTEGER)',
);
for (const migration of migrations.slice(0, 2)) {
for (const sql of migration.sql) old.exec(sql);
old
.prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)')
.run(migration.hash, migration.folderMillis);
}
const now = Date.now();
for (const id of ['a', 'b'])
old
.prepare('INSERT INTO guest_sessions(id,tokenHash,expiresAt,createdAt) VALUES (?,?,?,?)')
.run(id, id, now + 86400000, now);
old
.prepare(
'INSERT INTO transfer_requests(id,mode,senderGuestId,recipientGuestId,kind,byteSize,count,status,createdAt,expiresAt) VALUES (?,?,?,?,?,?,?,?,?,?)',
)
.run('request', 'local', 'a', 'b', 'text', 4, 1, 'accepted', now, now + 60000);
old
.prepare('INSERT INTO transfer_batches(id,requestId,status) VALUES (?,?,?)')
.run('batch', 'request', 'stored');
old
.prepare('INSERT INTO items(id,batchId,kind,text,createdAt,expiresAt) VALUES (?,?,?,?,?,?)')
.run('item', 'batch', 'text', 'keep', now, now + 86400000);
old
.prepare('INSERT INTO item_access(id,itemId,guestId,role,read) VALUES (?,?,?,?,?)')
.run('access', 'item', 'b', 'recipient', 0);
old.close();
const { sqlite } = openDatabase(path);
assert.equal(
(sqlite.prepare('SELECT text FROM items WHERE id=?').get('item') as any).text,
'keep',
);
assert.equal((sqlite.prepare('SELECT count(*) AS n FROM item_access').get() as any).n, 1);
assert.equal(sqlite.pragma('foreign_key_check').length, 0);
assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1);
sqlite.close();
} finally {
await rm(folder, { recursive: true, force: true });
}
});
test('업로드 확정 재시도는 같은 키와 바이트에만 동일 결과를 반환', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [{ name: 'retry.txt', size: 4, type: 'text/plain' }]),
key = randomUUID();
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
const first = await upload(f, a, id, 'retry.txt', Buffer.from('same'), 0, key),
second = await upload(f, a, id, 'retry.txt', Buffer.from('same'), 0, key);
assert.equal(first.statusCode, 200);
assert.deepEqual(first.json(), second.json());
assert.equal(
(await upload(f, a, id, 'retry.txt', Buffer.from('diff'), 0, key)).statusCode,
409,
);
assert.equal((await f.call(b, '/device/items')).json().items.length, 1);
} finally {
await f.close();
}
});
test('실제 HTTP 업로드 중단 시 임시 파일과 해당 파일 예약을 회수', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [
{ name: 'abort.bin', size: 1048576, type: 'application/octet-stream' },
]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
const { Readable } = await import('node:stream');
const controller = new AbortController(),
boundary = 'hop-abort';
async function* bytes() {
yield Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="abort.bin"\r\nContent-Type: application/octet-stream\r\n\r\n`,
);
yield Buffer.alloc(65536);
while (!controller.signal.aborted) await new Promise((r) => setTimeout(r, 10));
}
const pending = fetch(f.url + `/api/uploads?requestId=${id}&fileIndex=0`, {
method: 'POST',
headers: {
Origin: 'http://hop.test',
Cookie: a.cookie,
'X-CSRF-Token': a.csrf,
'Idempotency-Key': randomUUID(),
'Content-Type': `multipart/form-data; boundary=${boundary}`,
},
body: Readable.from(bytes()),
duplex: 'half',
signal: controller.signal,
} as any).catch(() => null);
for (let i = 0; i < 100 && f.service.storage.fileRows(id)[0]?.status !== 'uploading'; i++)
await new Promise((r) => setTimeout(r, 10));
assert.equal(f.service.storage.fileRows(id)[0]!.status, 'uploading');
controller.abort();
await pending;
for (let i = 0; i < 100 && f.service.storage.fileRows(id)[0]?.status !== 'failed'; i++)
await new Promise((r) => setTimeout(r, 10));
assert.equal(f.service.storage.fileRows(id)[0]!.status, 'failed');
assert.equal((await readdir(f.service.storage.temp)).length, 0);
assert.equal(f.service.db.select().from(s.reservations).all().length, 0);
} finally {
await f.close();
}
});

View File

@@ -0,0 +1,470 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { fixture, urlFor } from './helpers.js';
import type { Mail } from '../src/auth.js';
import { normalizeEmail } from '../src/auth.js';
import * as s from '../src/db/schema.js';
const password = 'correct horse battery staple';
test('개발은 1자 비밀번호를 허용하고 운영은 12자 규칙을 유지', async () => {
const f = await fixture();
try {
assert.equal((await f.call(null, '/auth/me')).json().passwordMinLength, 1);
assert.match(await f.service.auth.password('1'), /^\$argon2id\$/);
await assert.rejects(() => f.service.auth.password(''));
f.cfg.production = true;
assert.equal((await f.call(null, '/auth/me')).json().passwordMinLength, 12);
await assert.rejects(() => f.service.auth.password('1234'), /12~1024/);
assert.match(await f.service.auth.password('123412341234'), /^\$argon2id\$/);
} finally {
await f.close();
}
});
async function accountFixture() {
const mails: Mail[] = [];
const f = await fixture(false, async (mail) => {
mails.push(mail);
});
f.cfg.registrationMode = 'open';
return { ...f, mails };
}
async function register(
f: Awaited<ReturnType<typeof accountFixture>>,
email: string,
verify = true,
) {
const c = await f.create();
const result = await f.call(c, '/auth/register', 'POST', {
email,
password,
passwordConfirmation: password,
displayName: email.split('@')[0],
});
assert.equal(result.statusCode, 200, result.body);
const mail = f.mails.find((m) => m.to === email)!;
const token = mail.text.match(/#verify=([A-Za-z0-9_-]+)/)![1]!;
if (verify)
assert.equal((await f.call(c, '/auth/verify-email', 'POST', { token })).statusCode, 200);
const login = await f.call(c, '/auth/login', 'POST', { email, password });
assert.equal(login.statusCode, 200, login.body);
c.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0];
c.socket?.disconnect();
await f.connect(c);
return { ...c, userId: login.json().account.id, token };
}
async function request(
f: Awaited<ReturnType<typeof accountFixture>>,
a: any,
email: string,
text = 'remote text',
) {
return legacyRequest(f, a, {
email,
kind: 'url',
byteSize: Buffer.byteLength(urlFor(text)),
count: 1,
});
}
// Reproduce requests already stored by the previous email system; this is not a public creation API.
async function legacyRequest(f: any, a: any, input: any) {
try {
const result = f.service.accounts.create(a.userId, input);
f.service.notifyTransfer(result.requestId);
return { statusCode: 200, body: JSON.stringify(result), json: () => result };
} catch (error: any) {
return {
statusCode: error.status,
body: error.message,
json: () => ({ message: error.message }),
};
}
}
test('가입은 메일 게이트웨이로 인증 토큰을 보내고 해시만 저장, 인증과 로그인 회전', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com', false);
assert.equal(f.mails.length, 1);
const user = f.service.db.select().from(s.users).get()!;
assert.match(user.passwordHash!, /^\$argon2id\$/);
assert.equal(f.service.db.select().from(s.accountTokens).get()!.tokenHash === a.token, false);
assert.equal((await request(f, a, 'alice@example.com')).statusCode, 403);
assert.equal(
(await f.call(a, '/auth/verify-email', 'POST', { token: a.token })).statusCode,
200,
);
assert.equal(
(await f.call(a, '/auth/verify-email', 'POST', { token: a.token })).statusCode,
400,
);
const oldCookie = a.cookie;
const again = await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password });
assert.equal(again.statusCode, 200);
assert.equal((await f.call({ ...a, cookie: oldCookie }, '/auth/me')).json().account, null);
assert.equal((await f.call(a, '/device/me')).json().device.id, a.deviceId);
} finally {
await f.close();
}
});
test('메일 미설정 상태에서 공개 가입을 열 수 없고 폐쇄 가입은 거절', async () => {
const f = await fixture();
try {
const a = await f.create();
const input = {
email: 'alice@example.com',
password,
passwordConfirmation: password,
displayName: 'Alice',
};
assert.equal((await f.call(a, '/auth/register', 'POST', input)).statusCode, 403);
f.cfg.registrationMode = 'open';
assert.equal((await f.call(a, '/auth/register', 'POST', input)).statusCode, 503);
} finally {
await f.close();
}
});
test('기존 이메일 자료: 낯선 계정은 메타데이터 요청만 만들고 수락 후 실제 전달 및 허용 관계', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com'),
c = await register(f, 'charlie@example.com');
const response = await request(f, a, 'bob@example.com', '비공개 원격 본문');
assert.equal(response.statusCode, 200, response.body);
assert.equal(response.json().requiresAcceptance, true);
const id = response.json().requestId;
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
assert.equal(
JSON.stringify((await f.call(b, '/transfer-requests?mode=email')).json()).includes(
'비공개 원격 본문',
),
false,
);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('비공개 원격 본문')] },
randomUUID(),
)
).statusCode,
409,
);
assert.equal(
(await f.call(c, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
404,
);
assert.equal(
(
await f.call(
b,
`/transfer-requests/${id}/accept`,
'POST',
{ allowSender: true },
randomUUID(),
)
).statusCode,
200,
);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('비공개 원격 본문')] },
randomUUID(),
)
).statusCode,
200,
);
const item = (await f.call(b, '/inbox')).json().items[0];
assert.equal(item.url, urlFor('비공개 원격 본문'));
assert.equal((await f.call(c, `/access/${item.accessId}`)).statusCode, 404);
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
await f.call(b, `/access/${item.accessId}/read`, 'PATCH', { read: true });
assert.equal((await f.call(a, '/sent')).json().items[0].recipientRead, true);
b.socket?.disconnect();
const second = await request(f, a, 'bob@example.com');
assert.equal(second.statusCode, 200);
assert.equal(second.json().requiresAcceptance, false);
assert.equal(
(
await f.call(
a,
`/transfers/${second.json().requestId}/complete`,
'POST',
{ urls: [urlFor('remote text')] },
randomUUID(),
)
).statusCode,
200,
);
assert.equal((await f.call(b, '/inbox')).json().items.length, 2);
} finally {
await f.close();
}
});
test('자신에게 저장은 요청 수락 없이 내 보관함이며 계정 간 상태 격리', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
const r = await request(f, a, 'alice@example.com');
assert.equal(r.statusCode, 200);
assert.equal(r.json().requiresAcceptance, false);
await f.call(
a,
`/transfers/${r.json().requestId}/complete`,
'POST',
{ urls: [urlFor('remote text')] },
randomUUID(),
);
const item = (await f.call(a, '/vault')).json().items[0];
assert.equal(item.role, 'self');
assert.equal((await f.call(a, '/inbox')).json().items.length, 0);
assert.equal(
(await f.call(b, `/access/${item.accessId}/read`, 'PATCH', { read: true })).statusCode,
404,
);
} finally {
await f.close();
}
});
test('기존 이메일 자료: 차단이 허용보다 우선하며 주소 오류는 일반화', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
const id = (await request(f, a, 'bob@example.com')).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', { allowSender: true }, randomUUID());
await f.call(b, `/account/rules/${a.userId}`, 'PUT', { action: 'block' });
const blocked = await request(f, a, 'bob@example.com'),
unknown = await request(f, a, 'nobody@example.com');
assert.equal(blocked.statusCode, 400);
assert.equal(unknown.statusCode, 400);
assert.equal(blocked.json().message, unknown.json().message);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('remote text')] },
randomUUID(),
)
).statusCode,
409,
);
} finally {
await f.close();
}
});
test('비밀번호 재설정 토큰은 일회용이며 모든 기존 세션을 무효화', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com');
const another = await f.call(a, '/auth/login', 'POST', {
email: 'alice@example.com',
password,
});
a.cookie =
a.cookie.split('; hop_account=')[0] +
'; ' +
String(another.headers['set-cookie']).split(';')[0];
await f.call(a, '/auth/forgot-password', 'POST', { email: 'alice@example.com' });
const mail = f.mails.at(-1)!,
token = mail.text.match(/#reset-password=([A-Za-z0-9_-]+)/)![1]!,
next = 'a different long password';
const response = await f.call(a, '/auth/reset-password', 'POST', {
token,
password: next,
passwordConfirmation: next,
});
assert.equal(response.statusCode, 200);
assert.equal((await f.call(a, '/auth/me')).json().account, null);
assert.equal(
(
await f.call(a, '/auth/reset-password', 'POST', {
token,
password: next,
passwordConfirmation: next,
})
).statusCode,
400,
);
assert.equal(
(await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password })).statusCode,
401,
);
assert.equal(
(await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password: next }))
.statusCode,
200,
);
} finally {
await f.close();
}
});
test('로그인 중 받은 자료는 받은 항목에 자동 기록하며 익명 가져오기는 제거', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await f.create();
await f.space(a, b);
await f.complete(b, a, '자동 기록');
const item = (await f.call(a, '/device/items')).json().items[0];
assert.equal(
(await f.call(a, `/access/${item.accessId}/save-to-account`, 'POST', {})).statusCode,
410,
);
const saved = (await f.call(a, '/inbox')).json().items[0];
assert.equal(saved.url, item.url);
assert.equal(saved.expiresAt, item.expiresAt);
assert.equal((await f.call(a, '/vault')).json().items.length, 0);
await f.call(a, '/auth/logout', 'POST', {});
assert.equal((await f.call(a, '/inbox')).statusCode, 401);
} finally {
await f.close();
}
});
test('즐겨찾기 영구 보관 제거와 만료된 자료의 실제 정리', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
const id = (await request(f, a, 'bob@example.com')).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('remote text')] },
randomUUID(),
);
const item = (await f.call(b, '/inbox')).json().items[0];
assert.equal(
(await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: true }))
.statusCode,
410,
);
f.service.db
.update(s.items)
.set({ expiresAt: Date.now() - 1 })
.run();
f.service.cleanup();
assert.equal((await f.call(b, '/favorites')).json().items.length, 0);
assert.equal((await f.call(a, '/sent')).json().items.length, 0);
await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: false });
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
} finally {
await f.close();
}
});
test('이메일 정규화는 도메인만 소문자화하고 제공자 점·별칭을 보존', () => {
assert.equal(normalizeEmail(' Alice.Smith+tag@EXAMPLE.COM '), 'Alice.Smith+tag@example.com');
assert.notEqual(normalizeEmail('Alice@example.com'), normalizeEmail('alice@example.com'));
});
async function fileUpload(
f: Awaited<ReturnType<typeof accountFixture>>,
a: any,
id: string,
bytes: Buffer,
) {
const boundary = 'hop-account-file';
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="account.txt"\r\nContent-Type: text/plain\r\n\r\n`,
),
bytes,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
return f.call(a, `/uploads?requestId=${id}&fileIndex=0`, 'POST', payload, randomUUID(), {
'content-type': `multipart/form-data; boundary=${boundary}`,
});
}
test('기존 이메일 자료: 계정 파일은 수락 후 저장되고 익명 상한과 계정 용량을 분리', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
f.cfg.totalGuestQuota = 1;
const bytes = Buffer.from('account file');
const response = await legacyRequest(f, a, {
email: 'bob@example.com',
kind: 'file',
byteSize: bytes.length,
count: 1,
files: [{ name: 'account.txt', size: bytes.length, type: 'text/plain' }],
});
assert.equal(response.statusCode, 200);
const id = response.json().requestId;
assert.equal((await fileUpload(f, a, id, bytes)).statusCode, 409);
f.cfg.userQuota = bytes.length - 1;
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
413,
);
f.cfg.userQuota = bytes.length;
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
const upload = await fileUpload(f, a, id, bytes);
assert.equal(upload.statusCode, 200, upload.body);
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(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: true }))
.statusCode,
410,
);
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
} finally {
await f.close();
}
});
test('로그인 수신 파일은 계정에 기록하되 보내는 익명 파일 소유권을 옮기지 않음', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
await f.space(a, b);
const bytes = Buffer.from('move my file');
const r = await f.call(
a,
'/transfer-requests',
'POST',
{
targetDeviceId: b.deviceId,
kind: 'file',
byteSize: bytes.length,
count: 1,
files: [{ name: 'account.txt', size: bytes.length, type: 'text/plain' }],
},
randomUUID(),
);
const id = r.json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal((await fileUpload(f, a, id, bytes)).statusCode, 200);
const item = (await f.call(a, '/device/items')).json().items[0];
assert.equal(
(await f.call(a, `/access/${item.accessId}/save-to-account`, 'POST', {})).statusCode,
410,
);
assert.equal(
f.service.storage.fileRows(id)[0]!.ownerGuestId,
f.service.deviceGuest(a.deviceId)!.guestId,
);
const received = (await f.call(b, '/inbox')).json().items[0];
assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes);
} finally {
await f.close();
}
});

View File

@@ -0,0 +1,40 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRefreshQueue } from '../../web/src/refreshQueue.js';
test('조회 중 들어온 수락 이벤트를 버리지 않고 최신 상태를 다시 읽는다', async () => {
let status = 'pending',
seen: string[] = [];
let release!: () => void, started!: () => void;
const firstStarted = new Promise<void>((resolve) => {
started = resolve;
});
const pause = new Promise<void>((resolve) => {
release = resolve;
});
const refresh = createRefreshQueue(async () => {
const snapshot = status;
if (!seen.length) {
started();
await pause;
}
seen.push(snapshot);
});
const initial = refresh();
await firstStarted;
status = 'accepted';
const update = refresh();
refresh();
release();
await Promise.all([initial, update]);
assert.deepEqual(seen, ['pending', 'accepted']);
});
test('실패한 조회 다음 요청은 정상 재시도', async () => {
let calls = 0;
const refresh = createRefreshQueue(async () => {
if (++calls === 1) throw Error('network');
});
await assert.rejects(refresh(), /network/);
await refresh();
assert.equal(calls, 2);
});

View File

@@ -0,0 +1,66 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import Database from 'better-sqlite3';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import { openDatabase } from '../src/db/index.js';
test('기존 30일·즐겨찾기 자료는 ID·본문·권한을 유지하며 변경 시점부터 최대 7일로 제한', async () => {
const folder = await mkdtemp(join(tmpdir(), 'hop-retention-'));
try {
const path = join(folder, 'existing.sqlite'),
old = new Database(path);
const migrations = readMigrationFiles({
migrationsFolder: join(import.meta.dirname, '../drizzle'),
});
old.exec(
'CREATE TABLE __drizzle_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL, created_at INTEGER)',
);
for (const migration of migrations.slice(0, 8)) {
for (const statement of migration.sql) old.exec(statement);
old
.prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)')
.run(migration.hash, migration.folderMillis);
}
const now = Date.now();
for (const id of ['a', 'b'])
old
.prepare('INSERT INTO guest_sessions(id,tokenHash,expiresAt,createdAt) VALUES (?,?,?,?)')
.run(id, id, now + 30 * 86400000, now);
old
.prepare(
'INSERT INTO transfer_requests(id,senderGuestId,recipientGuestId,kind,byteSize,count,status,createdAt,expiresAt) VALUES (?,?,?,?,?,?,?,?,?)',
)
.run('request', 'a', 'b', 'url', 1, 1, 'accepted', now, now + 60000);
old
.prepare('INSERT INTO transfer_batches(id,requestId,status) VALUES (?,?,?)')
.run('batch', 'request', 'stored');
old
.prepare('INSERT INTO items(id,batchId,kind,url,createdAt,expiresAt) VALUES (?,?,?,?,?,?)')
.run('item', 'batch', 'url', 'https://example.com/existing', now, now + 30 * 86400000);
old
.prepare('INSERT INTO item_access(id,itemId,guestId,role,favorite) VALUES (?,?,?,?,?)')
.run('access', 'item', 'b', 'recipient', 1);
old.close();
const migrated = openDatabase(path);
try {
const item = migrated.sqlite.prepare('SELECT * FROM items WHERE id=?').get('item');
assert.equal(item.url, 'https://example.com/existing');
assert(
item.expiresAt <= Date.now() + 7 * 86400000 && item.expiresAt >= now + 7 * 86400000 - 1500,
);
const access = migrated.sqlite.prepare('SELECT * FROM item_access WHERE id=?').get('access');
assert.equal(access.guestId, 'b');
assert.equal(access.favorite, 0);
assert.equal(access.note, null);
assert.equal(migrated.sqlite.pragma('foreign_key_check').length, 0);
assert.equal(migrated.sqlite.pragma('integrity_check', { simple: true }), 'ok');
} finally {
migrated.sqlite.close();
}
} finally {
await rm(folder, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,90 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
test('주변 발견은 방 참여자를 제외하고 한 기기는 한 방만 참여, 방 종료 후 모두 주변으로 복귀', async () => {
const f = await fixture(true);
try {
const a = await f.create('8.8.8.8'),
b = await f.create('8.8.8.8'),
c = await f.create('8.8.8.8');
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 2);
const pending = (await f.request(a, b)).json().requestId;
const room = (await f.call(a, '/spaces', 'POST', {})).json();
assert.equal(
f.service.db.select().from(s.requests).where(eq(s.requests.id, pending)).get().status,
'cancelled',
);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal(
(await f.call(b, '/discovery/devices'))
.json()
.devices.some((device) => device.id === a.deviceId),
false,
);
assert.equal((await f.request(b, a)).statusCode, 403);
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: room.code })).statusCode, 200);
assert.equal((await f.call(b, '/discovery/devices')).json().devices.length, 1);
assert.equal((await f.call(b, '/spaces', 'POST', {})).statusCode, 409);
const other = (await f.call(c, '/spaces', 'POST', {})).json();
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: other.code })).statusCode, 409);
assert.throws(
() =>
f.service.db
.insert(s.members)
.values({
id: randomUUID(),
spaceId: other.space.id,
deviceId: b.deviceId,
status: 'approved',
joinedAt: Date.now(),
})
.run(),
/UNIQUE/,
);
assert.equal((await f.call(a, `/spaces/${room.space.id}`, 'DELETE')).statusCode, 200);
assert.equal((await f.call(b, '/spaces')).json().spaces.length, 0);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1);
await f.call(c, `/spaces/${other.space.id}`, 'DELETE');
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 2);
assert.equal((await f.call(b, '/spaces', 'POST', {})).statusCode, 200);
} finally {
await f.close();
}
});
test('승인 대기 참여자도 주변에서 숨고 방장이 승인한 뒤에만 같은 방 전송 가능', async () => {
const f = await fixture(true);
try {
const a = await f.create('8.8.8.8'),
b = await f.create('8.8.8.8'),
c = await f.create('8.8.8.8');
const room = await f.space(a, b, true);
assert.equal((await f.call(c, '/discovery/devices')).json().devices.length, 0);
assert.equal((await f.request(a, b)).statusCode, 403);
await f.call(a, `/spaces/${room.space.id}/members/${b.deviceId}/approve`, 'POST', {});
const request = await f.request(a, b);
assert.equal(request.statusCode, 200);
await f.call(
b,
`/transfer-requests/${request.json().requestId}/accept`,
'POST',
{},
randomUUID(),
);
await f.call(b, `/spaces/${room.space.id}/members/${b.deviceId}`, 'DELETE');
assert.equal(
f.service.db
.select()
.from(s.requests)
.where(eq(s.requests.id, request.json().requestId))
.get().status,
'cancelled',
);
} finally {
await f.close();
}
});

View File

@@ -0,0 +1,409 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
async function signed(f, client, email) {
const id = randomUUID();
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 login = await f.call(client, '/auth/login', 'POST', { email, password: '1234' });
assert.equal(login.statusCode, 200, login.body);
client.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0];
return id;
}
async function upload(f, client, id, bytes) {
const boundary = 'hop-vault-test';
const payload = 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`),
]);
return f.call(client, `/uploads?requestId=${id}&fileIndex=0`, 'POST', payload, randomUUID(), {
'content-type': `multipart/form-data; boundary=${boundary}`,
});
}
function limits(f) {
f.cfg.sessionRate = 30;
f.cfg.pairRate = 30;
f.cfg.ipRate = 60;
}
test('기기 수신은 로그인 계정의 날짜 기록에만 자동 저장, 익명 과거 자료와 로그아웃 후 도착은 가져오지 않음', async () => {
const f = await fixture();
try {
limits(f);
const a = await f.create(),
b = await f.create();
await f.space(a, b);
await f.complete(a, b, 'https://example.com/before');
await signed(f, b, 'receiver@hop.test');
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
await f.complete(a, b, 'https://example.com/after');
const item = (await f.call(b, '/inbox')).json().items[0];
assert.equal(item.url, 'https://example.com/after');
assert.equal(item.role, 'recipient');
assert.equal((await f.call(b, '/vault')).json().items.length, 0);
assert(item.expiresAt - item.createdAt <= 7 * 86400000);
const guestItem = (await f.call(b, '/device/items'))
.json()
.items.find((i) => i.url === item.url);
await f.call(b, `/access/${guestItem.accessId}/read`, 'PATCH', { read: true });
assert.equal((await f.call(b, '/inbox')).json().items[0].read, true);
const request = (await f.request(a, b, 'https://example.com/logged-out')).json().requestId;
await f.call(b, `/transfer-requests/${request}/accept`, 'POST', {}, randomUUID());
await f.call(b, '/auth/logout', 'POST', {});
await f.call(
a,
`/transfers/${request}/complete`,
'POST',
{ urls: ['https://example.com/logged-out'] },
randomUUID(),
);
const login = await f.call(b, '/auth/login', 'POST', {
email: 'receiver@hop.test',
password: '1234',
});
b.cookie =
b.cookie.split('; hop_account=')[0] +
'; ' +
String(login.headers['set-cookie']).split(';')[0];
assert.equal((await f.call(b, '/inbox')).json().items.length, 1);
} finally {
await f.close();
}
});
test('임시 링크 보관: 메모, 7일, 중복 방지, 타인 접근 차단과 이메일 전송 제거', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await signed(f, a, 'owner@hop.test');
await signed(f, b, 'other@hop.test');
const key = randomUUID(),
input = { kind: 'url', urls: ['https://example.com/saved'], note: '나중에 읽을 글' };
const result = await f.call(a, '/vault', 'POST', input, key);
assert.equal(result.statusCode, 200, result.body);
assert.equal(
(await f.call(a, '/vault', 'POST', input, key)).json().requestId,
result.json().requestId,
);
const list = (await f.call(a, '/vault')).json().items;
assert.equal(list.length, 1);
const item = list[0];
assert.equal(item.note, input.note);
assert.equal(item.expiresAt - item.createdAt, 7 * 86400000);
assert.equal((await f.call(a, '/inbox')).json().items.length, 0);
assert.equal(
(await f.call(b, `/vault/${item.accessId}/note`, 'PATCH', { note: 'stolen' })).statusCode,
404,
);
assert.equal(
(await f.call(a, `/vault/${item.accessId}/note`, 'PATCH', { note: '새 메모' })).statusCode,
200,
);
assert.equal((await f.call(a, '/vault?search=새')).json().items.length, 1);
assert.equal(
(await f.call(a, '/vault', 'POST', { ...input, note: '줄\n바꿈' }, randomUUID())).statusCode,
400,
);
assert.equal(
(await f.call(a, '/vault', 'POST', { kind: 'file', files: [] }, randomUUID())).statusCode,
400,
);
assert.equal(
(
await f.call(
a,
'/vault',
'POST',
{ kind: 'url', urls: ['javascript:alert(1)'] },
randomUUID(),
)
).statusCode,
400,
);
assert.equal(
(
await f.call(
a,
'/transfer-requests',
'POST',
{ mode: 'email', email: 'other@hop.test', kind: 'url', count: 1, byteSize: 1 },
randomUUID(),
)
).statusCode,
410,
);
} finally {
await f.close();
}
});
test('보관함 링크 전송은 수락 전 비공개이며 수락 후 서버에서 전달, 삭제된 원본과 계정 세션 변경은 차단', async () => {
const f = await fixture();
try {
limits(f);
const a = await f.create(),
b = await f.create(),
c = await f.create();
await signed(f, a, 'owner@hop.test');
await signed(f, b, 'receiver@hop.test');
await signed(f, c, 'other@hop.test');
await f.space(a, b);
await f.call(
a,
'/vault',
'POST',
{ kind: 'url', urls: ['https://example.com/private'] },
randomUUID(),
);
const item = (await f.call(a, '/vault')).json().items[0];
assert.equal(
(
await f.call(
c,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
)
).statusCode,
404,
);
const sent = await f.call(
a,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
assert.equal(sent.statusCode, 200, sent.body);
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
const requests = (await f.call(b, '/transfer-requests')).json().requests;
assert(!JSON.stringify(requests).includes(item.url));
assert.equal(
(
await f.call(
b,
`/transfer-requests/${sent.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
200,
);
assert.equal((await f.call(b, '/inbox')).json().items[0].url, item.url);
const next = await f.call(
a,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
await f.call(a, '/access/delete-batch', 'POST', { ids: [item.accessId] });
assert.equal(
(
await f.call(
b,
`/transfer-requests/${next.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
404,
);
f.service.cleanup();
assert.equal((await f.call(b, '/inbox')).json().items.length, 1);
await f.call(a, `/transfer-requests/${next.json().requestId}/cancel`, 'POST', {});
await f.call(
a,
'/vault',
'POST',
{ kind: 'url', urls: ['https://example.com/session'] },
randomUUID(),
);
const another = (await f.call(a, '/vault')).json().items[0];
const revokedSource = await f.call(
a,
`/vault/${another.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
await f.call(a, '/auth/logout', 'POST', {});
assert.equal(
(
await f.call(
b,
`/transfer-requests/${revokedSource.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
401,
);
assert.equal((await f.call(b, '/inbox')).json().items.length, 1);
} finally {
await f.close();
}
});
test('보관함 파일은 저장된 바이트를 재사용, 수신 계정 기록·다운로드·원본 만료 후 상대 권한 유지와 마지막 참조 정리', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await signed(f, a, 'owner@hop.test');
await signed(f, b, 'receiver@hop.test');
await f.space(a, b);
const bytes = Buffer.from('stored vault file');
const saved = await f.call(
a,
'/vault',
'POST',
{
kind: 'file',
files: [{ name: 'saved.txt', size: bytes.length, type: 'text/plain' }],
note: '업무 자료',
},
randomUUID(),
);
assert.equal(saved.statusCode, 200, saved.body);
assert.equal((await upload(f, a, saved.json().requestId, bytes)).statusCode, 200);
const item = (await f.call(a, '/vault')).json().items[0];
assert.equal(item.note, '업무 자료');
const sent = await f.call(
a,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
assert.equal(sent.statusCode, 200, sent.body);
assert.equal(
(
await f.call(
b,
`/transfer-requests/${sent.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
200,
);
const received = (await f.call(b, '/inbox')).json().items[0];
assert.equal(received.file.id, item.file.id);
assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes);
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, bytes.length);
const file = f.service.db.select().from(s.files).where(eq(s.files.id, item.file.id)).get();
f.service.db
.update(s.items)
.set({ expiresAt: Date.now() - 1 })
.where(eq(s.items.id, item.id))
.run();
f.service.cleanup();
await f.service.storage.cleanup();
assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes);
f.service.db
.update(s.items)
.set({ expiresAt: Date.now() - 1 })
.run();
f.service.cleanup();
await f.service.storage.cleanup();
assert.equal((await f.call(b, `/access/${received.accessId}/download`)).statusCode, 404);
assert.equal(existsSync(resolve(f.service.storage.originals, file.storageKey)), false);
assert.equal(f.service.db.select().from(s.items).all().length, 0);
f.service.db
.update(s.requests)
.set({ createdAt: Date.now() - 8 * 86400000, note: '만료된 메모', manifest: '[]' })
.where(eq(s.requests.id, saved.json().requestId))
.run();
f.service.cleanup();
const expired = f.service.db
.select()
.from(s.requests)
.where(eq(s.requests.id, saved.json().requestId))
.get();
assert.equal(expired.note, null);
assert.equal(expired.manifest, null);
} finally {
await f.close();
}
});
test('보관함 콘텐츠에는 직접 저장과 받은 항목이 포함되며 자기 수신 기록도 같은 방 기기에 전달 가능', async () => {
const f = await fixture();
try {
limits(f);
const a = await f.create(),
b = await f.create(),
c = await f.create();
await signed(f, a, 'a@hop.test');
await signed(f, b, 'b@hop.test');
await signed(f, c, 'c@hop.test');
await f.space(a, b);
await f.space(a, c);
await f.complete(a, b, 'https://example.com/received-source');
await f.call(
b,
'/vault',
'POST',
{ kind: 'url', urls: ['https://example.com/saved-source'] },
randomUUID(),
);
const sources = (await f.call(b, '/vault/contents')).json().items;
assert.equal(sources.length, 2);
assert(sources.some((item) => item.role === 'recipient'));
assert(sources.some((item) => item.role === 'self'));
const received = sources.find((item) => item.role === 'recipient');
const request = await f.call(
b,
`/vault/${received.accessId}/send`,
'POST',
{ targetDeviceId: c.deviceId },
randomUUID(),
);
assert.equal(request.statusCode, 200, request.body);
assert.equal(
(
await f.call(
c,
`/transfer-requests/${request.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
200,
);
assert.equal(
(await f.call(c, '/inbox')).json().items[0].url,
'https://example.com/received-source',
);
assert.equal((await f.call(a, '/vault/contents')).json().items.length, 0);
} finally {
await f.close();
}
});

11
apps/server/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"allowJs": true
},
"include": ["src"]
}