Initial hop. implementation and UGREEN NAS deployment guide
This commit is contained in:
2
apps/server/drizzle.config.ts
Normal file
2
apps/server/drizzle.config.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
export default defineConfig({ schema: './src/db/schema.ts', out: './drizzle', dialect: 'sqlite' });
|
||||
126
apps/server/drizzle/0000_aberrant_scourge.sql
Normal file
126
apps/server/drizzle/0000_aberrant_scourge.sql
Normal 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`);
|
||||
1
apps/server/drizzle/0001_numerous_dust.sql
Normal file
1
apps/server/drizzle/0001_numerous_dust.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `transfer_requests` ADD `rejectedAt` integer;
|
||||
71
apps/server/drizzle/0002_orange_norrin_radd.sql
Normal file
71
apps/server/drizzle/0002_orange_norrin_radd.sql
Normal 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;
|
||||
1
apps/server/drizzle/0003_spooky_blob.sql
Normal file
1
apps/server/drizzle/0003_spooky_blob.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `files` ADD `uploadKey` text;
|
||||
191
apps/server/drizzle/0004_bright_wildside.sql
Normal file
191
apps/server/drizzle/0004_bright_wildside.sql
Normal 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`);
|
||||
17
apps/server/drizzle/0005_tan_miracleman.sql
Normal file
17
apps/server/drizzle/0005_tan_miracleman.sql
Normal 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;
|
||||
3
apps/server/drizzle/0006_file_url_only.sql
Normal file
3
apps/server/drizzle/0006_file_url_only.sql
Normal 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');
|
||||
21
apps/server/drizzle/0007_rapid_morph.sql
Normal file
21
apps/server/drizzle/0007_rapid_morph.sql
Normal 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;
|
||||
10
apps/server/drizzle/0008_temporary-retention.sql
Normal file
10
apps/server/drizzle/0008_temporary-retention.sql
Normal 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');
|
||||
1
apps/server/drizzle/0009_vault-note.sql
Normal file
1
apps/server/drizzle/0009_vault-note.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `transfer_requests` ADD `note` text;
|
||||
7
apps/server/drizzle/0010_single-room.sql
Normal file
7
apps/server/drizzle/0010_single-room.sql
Normal 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);
|
||||
1
apps/server/drizzle/0011_single-room-index.sql
Normal file
1
apps/server/drizzle/0011_single-room-index.sql
Normal file
@@ -0,0 +1 @@
|
||||
CREATE UNIQUE INDEX `member_single_active_device` ON `space_members` (`deviceId`) WHERE "space_members"."status" IN ('pending', 'approved');
|
||||
894
apps/server/drizzle/meta/0000_snapshot.json
Normal file
894
apps/server/drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
901
apps/server/drizzle/meta/0001_snapshot.json
Normal file
901
apps/server/drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
1275
apps/server/drizzle/meta/0002_snapshot.json
Normal file
1275
apps/server/drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1282
apps/server/drizzle/meta/0003_snapshot.json
Normal file
1282
apps/server/drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1853
apps/server/drizzle/meta/0004_snapshot.json
Normal file
1853
apps/server/drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1853
apps/server/drizzle/meta/0005_snapshot.json
Normal file
1853
apps/server/drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1853
apps/server/drizzle/meta/0006_snapshot.json
Normal file
1853
apps/server/drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2022
apps/server/drizzle/meta/0007_snapshot.json
Normal file
2022
apps/server/drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2022
apps/server/drizzle/meta/0008_snapshot.json
Normal file
2022
apps/server/drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2029
apps/server/drizzle/meta/0009_snapshot.json
Normal file
2029
apps/server/drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2029
apps/server/drizzle/meta/0010_snapshot.json
Normal file
2029
apps/server/drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2037
apps/server/drizzle/meta/0011_snapshot.json
Normal file
2037
apps/server/drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
90
apps/server/drizzle/meta/_journal.json
Normal file
90
apps/server/drizzle/meta/_journal.json
Normal 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
32
apps/server/package.json
Normal 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
667
apps/server/src/accounts.ts
Normal 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
828
apps/server/src/app.ts
Normal 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
374
apps/server/src/auth.ts
Normal 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
106
apps/server/src/config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
57
apps/server/src/db/index.ts
Normal file
57
apps/server/src/db/index.ts
Normal 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'];
|
||||
354
apps/server/src/db/schema.ts
Normal file
354
apps/server/src/db/schema.ts
Normal 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
48
apps/server/src/mail.ts
Normal 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
13
apps/server/src/main.ts
Normal 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();
|
||||
});
|
||||
77
apps/server/src/presence.ts
Normal file
77
apps/server/src/presence.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
84
apps/server/src/security.ts
Normal file
84
apps/server/src/security.ts
Normal 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
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
596
apps/server/src/storage.ts
Normal 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
141
apps/server/src/vault.js
Normal 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
149
apps/server/test/helpers.ts
Normal 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 };
|
||||
}
|
||||
66
apps/server/test/mail.test.ts
Normal file
66
apps/server/test/mail.test.ts
Normal 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'),
|
||||
);
|
||||
}
|
||||
});
|
||||
74
apps/server/test/migrations.test.ts
Normal file
74
apps/server/test/migrations.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
557
apps/server/test/phase1.test.ts
Normal file
557
apps/server/test/phase1.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
411
apps/server/test/phase2.test.ts
Normal file
411
apps/server/test/phase2.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
470
apps/server/test/phase3.test.ts
Normal file
470
apps/server/test/phase3.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
40
apps/server/test/refresh-queue.test.ts
Normal file
40
apps/server/test/refresh-queue.test.ts
Normal 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);
|
||||
});
|
||||
66
apps/server/test/retention-migration.test.js
Normal file
66
apps/server/test/retention-migration.test.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
90
apps/server/test/rooms.test.js
Normal file
90
apps/server/test/rooms.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
409
apps/server/test/vault.test.js
Normal file
409
apps/server/test/vault.test.js
Normal 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
11
apps/server/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
14
apps/web/index.html
Normal file
14
apps/web/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<title>hop. — 기기 사이를 가볍게.</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
apps/web/package.json
Normal file
22
apps/web/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@hop/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hop/shared": "0.1.0",
|
||||
"@lucide/vue": "^1.53.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"vue": "^3.5.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
1
apps/web/public/icon.svg
Normal file
1
apps/web/public/icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#111111"/><path d="M18 14v36h8V34c0-8 12-8 12 0v16h8V32c0-15-13-17-20-9v-9z" fill="#fff"/><circle cx="52" cy="48" r="4" fill="#ffffff"/></svg>
|
||||
|
After Width: | Height: | Size: 246 B |
753
apps/web/src/AccountPanel.vue
Normal file
753
apps/web/src/AccountPanel.vue
Normal file
@@ -0,0 +1,753 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ArrowRight,
|
||||
Inbox,
|
||||
Send,
|
||||
LogOut,
|
||||
Copy,
|
||||
Download,
|
||||
Trash2,
|
||||
Settings,
|
||||
Check,
|
||||
X,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
Link,
|
||||
File as FileIcon,
|
||||
Pencil,
|
||||
} from '@lucide/vue';
|
||||
import SendComposer from './SendComposer.vue';
|
||||
import { createRefreshQueue } from './refreshQueue';
|
||||
import {
|
||||
api,
|
||||
account,
|
||||
registrationOpen,
|
||||
passwordMinLength,
|
||||
refreshAccount,
|
||||
reconnect,
|
||||
showError,
|
||||
refresh,
|
||||
uploadFile,
|
||||
clearAccountUploads,
|
||||
cancelTransfer,
|
||||
uploadProgress,
|
||||
} from './client';
|
||||
const props = defineProps({ initialView: { type: String, default: 'vault' } });
|
||||
const emit = defineEmits(['devices', 'send-item']);
|
||||
const view = ref(props.initialView === 'inbox' ? 'inbox' : 'vault');
|
||||
const authMode = ref('login'),
|
||||
email = ref(''),
|
||||
password = ref(''),
|
||||
confirmation = ref(''),
|
||||
displayName = ref(''),
|
||||
token = ref(''),
|
||||
authMessage = ref('');
|
||||
const busy = ref(false),
|
||||
notice = ref(''),
|
||||
items = ref([]),
|
||||
nextCursor = ref(null),
|
||||
search = ref(''),
|
||||
selected = ref([]),
|
||||
undo = ref([]),
|
||||
now = ref(Date.now());
|
||||
const compose = ref(false),
|
||||
saveKind = ref(null),
|
||||
note = ref(''),
|
||||
saveTransfer = ref(null),
|
||||
preferencesOpen = ref(false),
|
||||
editing = ref(null),
|
||||
noteDraft = ref('');
|
||||
const storage = ref({ usedBytes: 0, limitBytes: 0 });
|
||||
const securityAction = ref('none'),
|
||||
currentPassword = ref(''),
|
||||
newPassword = ref(''),
|
||||
newConfirmation = ref(''),
|
||||
newEmail = ref('');
|
||||
let timer;
|
||||
function message(text) {
|
||||
notice.value = text;
|
||||
setTimeout(() => {
|
||||
if (notice.value === text) notice.value = '';
|
||||
}, 4000);
|
||||
}
|
||||
async function act(fn) {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
function size(value) {
|
||||
return value >= 1048576
|
||||
? (value / 1048576).toFixed(1) + ' MiB'
|
||||
: value >= 1024
|
||||
? (value / 1024).toFixed(1) + ' KiB'
|
||||
: value + ' B';
|
||||
}
|
||||
function date(value) {
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(value);
|
||||
}
|
||||
const groups = computed(() => {
|
||||
const grouped = new Map();
|
||||
for (const item of items.value) {
|
||||
const day = new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(item.createdAt);
|
||||
if (!grouped.has(day)) grouped.set(day, []);
|
||||
grouped.get(day).push(item);
|
||||
}
|
||||
return [...grouped].map(([date, items]) => ({ date, items }));
|
||||
});
|
||||
function clearPrivate() {
|
||||
items.value = [];
|
||||
nextCursor.value = null;
|
||||
selected.value = [];
|
||||
search.value = '';
|
||||
undo.value = [];
|
||||
compose.value = false;
|
||||
saveTransfer.value = null;
|
||||
note.value = '';
|
||||
editing.value = null;
|
||||
preferencesOpen.value = false;
|
||||
password.value = '';
|
||||
confirmation.value = '';
|
||||
currentPassword.value = '';
|
||||
newPassword.value = '';
|
||||
newConfirmation.value = '';
|
||||
newEmail.value = '';
|
||||
securityAction.value = 'none';
|
||||
}
|
||||
const refreshLists = createRefreshQueue(async () => {
|
||||
if (!account.value?.verified) return;
|
||||
const owner = account.value.id;
|
||||
const query = new URLSearchParams();
|
||||
if (search.value) query.set('search', search.value);
|
||||
const [list, quota] = await Promise.all([
|
||||
api(`/${view.value}?${query}`),
|
||||
api('/account/storage'),
|
||||
]);
|
||||
if (account.value?.id !== owner) return;
|
||||
items.value = list.items;
|
||||
nextCursor.value = list.nextCursor;
|
||||
storage.value = quota;
|
||||
});
|
||||
watch(view, () => {
|
||||
selected.value = [];
|
||||
search.value = '';
|
||||
void refreshLists().catch(showError);
|
||||
});
|
||||
watch(
|
||||
() => account.value?.id,
|
||||
(id, previous) => {
|
||||
if (id !== previous) clearPrivate();
|
||||
},
|
||||
);
|
||||
function tokenFromUrl() {
|
||||
const params = new URLSearchParams(location.hash.slice(1));
|
||||
for (const [key, mode] of [
|
||||
['verify', 'verify'],
|
||||
['reset-password', 'reset'],
|
||||
['verify-email-change', 'email-change'],
|
||||
]) {
|
||||
const value = params.get(key);
|
||||
if (value) {
|
||||
token.value = value;
|
||||
authMode.value = mode;
|
||||
history.replaceState(null, '', location.pathname);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function authSubmit() {
|
||||
await act(async () => {
|
||||
authMessage.value = '';
|
||||
if (authMode.value === 'login') {
|
||||
await api('/auth/login', 'POST', { email: email.value, password: password.value });
|
||||
clearPrivate();
|
||||
await refreshAccount();
|
||||
reconnect();
|
||||
await refreshLists();
|
||||
return;
|
||||
}
|
||||
if (authMode.value === 'register') {
|
||||
const result = await api('/auth/register', 'POST', {
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
passwordConfirmation: confirmation.value,
|
||||
displayName: displayName.value,
|
||||
});
|
||||
authMessage.value = result.message;
|
||||
password.value = '';
|
||||
confirmation.value = '';
|
||||
return;
|
||||
}
|
||||
if (authMode.value === 'forgot') {
|
||||
const result = await api('/auth/forgot-password', 'POST', {
|
||||
email: email.value,
|
||||
});
|
||||
authMessage.value = result.message;
|
||||
return;
|
||||
}
|
||||
if (authMode.value === 'verify' || authMode.value === 'email-change') {
|
||||
await api(
|
||||
authMode.value === 'verify' ? '/auth/verify-email' : '/auth/verify-email-change',
|
||||
'POST',
|
||||
{ token: token.value },
|
||||
);
|
||||
token.value = '';
|
||||
authMode.value = 'login';
|
||||
authMessage.value = '이메일 인증을 완료했습니다. 로그인해 주세요.';
|
||||
await refreshAccount();
|
||||
reconnect();
|
||||
await refreshLists();
|
||||
return;
|
||||
}
|
||||
await api('/auth/reset-password', 'POST', {
|
||||
token: token.value,
|
||||
password: password.value,
|
||||
passwordConfirmation: confirmation.value,
|
||||
});
|
||||
token.value = '';
|
||||
authMode.value = 'login';
|
||||
authMessage.value = '비밀번호를 변경했습니다. 다시 로그인해 주세요.';
|
||||
password.value = '';
|
||||
confirmation.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
async function save(values) {
|
||||
if (busy.value || saveTransfer.value) return;
|
||||
busy.value = true;
|
||||
let id;
|
||||
try {
|
||||
const result = await api(
|
||||
'/vault',
|
||||
'POST',
|
||||
{
|
||||
kind: values.files ? 'file' : 'url',
|
||||
...(values.files
|
||||
? {
|
||||
files: values.files.map((file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type || 'application/octet-stream',
|
||||
})),
|
||||
}
|
||||
: { urls: values.urls }),
|
||||
note: note.value,
|
||||
},
|
||||
crypto.randomUUID(),
|
||||
);
|
||||
id = result.requestId;
|
||||
if (values.files) {
|
||||
saveTransfer.value = { id, kind: 'file', status: 'accepted' };
|
||||
busy.value = false;
|
||||
for (const [index, file] of values.files.entries()) {
|
||||
if (saveTransfer.value?.id !== id) return;
|
||||
await uploadFile(id, index, file, account.value.id);
|
||||
}
|
||||
if (saveTransfer.value?.id !== id) return;
|
||||
}
|
||||
await refreshLists();
|
||||
compose.value = false;
|
||||
note.value = '';
|
||||
message('임시 보관함에 저장했습니다.');
|
||||
} catch (error) {
|
||||
if (!id || saveTransfer.value?.id === id || !values.files) showError(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
if (saveTransfer.value?.id === id) saveTransfer.value = null;
|
||||
}
|
||||
}
|
||||
async function cancelSave() {
|
||||
const id = saveTransfer.value?.id;
|
||||
if (!id) return;
|
||||
await act(async () => {
|
||||
saveTransfer.value = null;
|
||||
await cancelTransfer(id);
|
||||
await refreshLists();
|
||||
});
|
||||
}
|
||||
async function read(item, value = true) {
|
||||
await api(`/access/${item.accessId}/read`, 'PATCH', { read: value });
|
||||
await refreshLists();
|
||||
}
|
||||
function recordLinkOpen(item) {
|
||||
void read(item).catch(showError);
|
||||
}
|
||||
async function copy(item) {
|
||||
await act(async () => {
|
||||
await navigator.clipboard.writeText(item.url);
|
||||
await read(item);
|
||||
message('복사했습니다.');
|
||||
});
|
||||
}
|
||||
async function download(item) {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/access/${item.accessId}/download`;
|
||||
a.download = item.file.name;
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
await read(item);
|
||||
}
|
||||
async function remove(ids) {
|
||||
await act(async () => {
|
||||
const operation = await api('/access/delete-batch', 'POST', { ids });
|
||||
undo.value.push(operation);
|
||||
selected.value = [];
|
||||
await refreshLists();
|
||||
});
|
||||
}
|
||||
async function restore(operation) {
|
||||
await act(async () => {
|
||||
await api(`/deletion-operations/${operation.operationId}/undo`, 'POST', {});
|
||||
undo.value = undo.value.filter((v) => v !== operation);
|
||||
await refreshLists();
|
||||
});
|
||||
}
|
||||
async function loadMoreAccount() {
|
||||
await act(async () => {
|
||||
if (!nextCursor.value) return;
|
||||
const result = await api(
|
||||
`/${view.value}?cursor=${encodeURIComponent(nextCursor.value)}&search=${encodeURIComponent(search.value)}`,
|
||||
);
|
||||
items.value.push(...result.items);
|
||||
nextCursor.value = result.nextCursor;
|
||||
});
|
||||
}
|
||||
async function saveNote(item) {
|
||||
await act(async () => {
|
||||
await api(`/vault/${item.accessId}/note`, 'PATCH', { note: noteDraft.value });
|
||||
editing.value = null;
|
||||
await refreshLists();
|
||||
});
|
||||
}
|
||||
async function securitySubmit() {
|
||||
await act(async () => {
|
||||
if (securityAction.value === 'password') {
|
||||
await api('/auth/change-password', 'POST', {
|
||||
currentPassword: currentPassword.value,
|
||||
password: newPassword.value,
|
||||
passwordConfirmation: newConfirmation.value,
|
||||
});
|
||||
clearPrivate();
|
||||
await refreshAccount();
|
||||
reconnect();
|
||||
message('비밀번호를 변경했습니다. 다시 로그인해 주세요.');
|
||||
} else {
|
||||
await api('/auth/change-email', 'POST', {
|
||||
email: newEmail.value,
|
||||
password: currentPassword.value,
|
||||
});
|
||||
currentPassword.value = '';
|
||||
newEmail.value = '';
|
||||
securityAction.value = 'none';
|
||||
message('새 주소의 인증 메일을 확인해 주세요. 인증 후 변경됩니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await act(async () => {
|
||||
clearAccountUploads(account.value?.id);
|
||||
await api('/auth/logout', 'POST', {});
|
||||
clearPrivate();
|
||||
await refreshAccount();
|
||||
reconnect();
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
const revoked = () => {
|
||||
clearPrivate();
|
||||
void refreshAccount().catch(showError);
|
||||
};
|
||||
const updated = () => void refreshLists().catch(showError);
|
||||
onMounted(async () => {
|
||||
tokenFromUrl();
|
||||
timer = setInterval(() => {
|
||||
now.value = Date.now();
|
||||
}, 1000);
|
||||
window.addEventListener('hop-account-updated', updated);
|
||||
window.addEventListener('hop-account-revoked', revoked);
|
||||
window.addEventListener('hashchange', tokenFromUrl);
|
||||
await act(async () => {
|
||||
await refreshAccount();
|
||||
await refreshLists();
|
||||
});
|
||||
});
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer);
|
||||
window.removeEventListener('hop-account-updated', updated);
|
||||
window.removeEventListener('hop-account-revoked', revoked);
|
||||
window.removeEventListener('hashchange', tokenFromUrl);
|
||||
clearPrivate();
|
||||
});
|
||||
async function resendVerification() {
|
||||
await act(async () => {
|
||||
const response = await api('/auth/resend-verification', 'POST', { email: account.value.email });
|
||||
message(response.message);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section v-if="!account || token" class="account-auth">
|
||||
<div class="eyebrow">ACROSS YOUR DEVICES</div>
|
||||
<h1>
|
||||
{{
|
||||
{
|
||||
login: '다시 가볍게.',
|
||||
register: '이메일로 시작하기.',
|
||||
forgot: '비밀번호 찾기.',
|
||||
verify: '이메일 인증.',
|
||||
reset: '새 비밀번호.',
|
||||
'email-change': '이메일 변경 인증.',
|
||||
}[authMode]
|
||||
}}
|
||||
</h1>
|
||||
<p>계정으로 저장하고, 다른 기기에서도 꺼내 쓰세요.</p>
|
||||
<form @submit.prevent="authSubmit">
|
||||
<label v-if="['login', 'register', 'forgot'].includes(authMode)"
|
||||
>이메일<input
|
||||
type="email"
|
||||
v-model="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="you@example.com" /></label
|
||||
><label v-if="authMode === 'register'"
|
||||
>표시 이름<input
|
||||
v-model="displayName"
|
||||
required
|
||||
maxlength="40"
|
||||
autocomplete="nickname" /></label
|
||||
><label v-if="['login', 'register', 'reset'].includes(authMode)"
|
||||
>비밀번호<input
|
||||
type="password"
|
||||
v-model="password"
|
||||
required
|
||||
:minlength="authMode === 'login' ? 1 : passwordMinLength"
|
||||
maxlength="1024"
|
||||
:autocomplete="authMode === 'login' ? 'current-password' : 'new-password'"
|
||||
:placeholder="authMode === 'login' ? '비밀번호' : `${passwordMinLength}자 이상`" /></label
|
||||
><label v-if="['register', 'reset'].includes(authMode)"
|
||||
>비밀번호 확인<input
|
||||
type="password"
|
||||
v-model="confirmation"
|
||||
required
|
||||
:minlength="passwordMinLength"
|
||||
maxlength="1024"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
<p v-if="authMessage" class="auth-message" role="status">{{ authMessage }}</p>
|
||||
<button class="button primary" type="submit" :disabled="busy">
|
||||
{{
|
||||
{
|
||||
login: '로그인',
|
||||
register: '가입하고 인증 메일 받기',
|
||||
forgot: '안내 메일 받기',
|
||||
verify: '이메일 인증 완료하기',
|
||||
reset: '비밀번호 변경하기',
|
||||
'email-change': '이메일 변경 확정하기',
|
||||
}[authMode]
|
||||
}}<ArrowRight :size="17" />
|
||||
</button>
|
||||
</form>
|
||||
<div class="auth-links">
|
||||
<button v-if="authMode === 'login'" class="text-button" @click="authMode = 'forgot'">
|
||||
비밀번호 찾기</button
|
||||
><button
|
||||
v-if="authMode === 'login' && registrationOpen"
|
||||
class="text-button"
|
||||
@click="authMode = 'register'"
|
||||
>
|
||||
이메일로 가입</button
|
||||
><button
|
||||
v-if="authMode !== 'login'"
|
||||
class="text-button"
|
||||
@click="
|
||||
authMode = 'login';
|
||||
token = '';
|
||||
"
|
||||
>
|
||||
로그인으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!registrationOpen && authMode === 'login'" class="muted">
|
||||
현재 공개 가입은 닫혀 있습니다.
|
||||
</p>
|
||||
<button class="text-button" @click="emit('devices')">
|
||||
가입 없이 기기로 보내기 <ArrowUpRight :size="15" />
|
||||
</button>
|
||||
</section>
|
||||
<section v-else-if="!account.verified" class="account-auth">
|
||||
<h1>이메일을 확인해 주세요<span>.</span></h1>
|
||||
<p>{{ account.email }} 인증 후 이메일 전송과 계정 보관함을 사용할 수 있어요.</p>
|
||||
<button class="button" @click="resendVerification">인증 메일 다시 받기</button
|
||||
><button class="text-button" @click="logout">로그아웃</button>
|
||||
</section>
|
||||
<section v-else class="account-workspace compact-workspace">
|
||||
<div class="account-heading compact-heading">
|
||||
<div>
|
||||
<h1>보관함</h1>
|
||||
<p>
|
||||
{{
|
||||
view === 'inbox'
|
||||
? '로그인 중 받은 링크와 파일을 날짜별로 모았어요.'
|
||||
: '나중에 보내거나 꺼내 쓸 링크와 파일을 담아두세요.'
|
||||
}}
|
||||
· 최대 7일
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="icon-button" @click="preferencesOpen = true" aria-label="계정 설정">
|
||||
<Settings :size="18" /></button
|
||||
><button class="icon-button" @click="logout" aria-label="로그아웃">
|
||||
<LogOut :size="18" /></button
|
||||
><button
|
||||
v-if="view === 'vault'"
|
||||
class="button primary"
|
||||
@click="
|
||||
compose = true;
|
||||
saveKind = null;
|
||||
note = '';
|
||||
"
|
||||
>
|
||||
<Plus :size="17" />보관함에 담기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="account-tabs" role="tablist" aria-label="보관함 목록">
|
||||
<button
|
||||
role="tab"
|
||||
:aria-selected="view === 'vault'"
|
||||
:class="{ active: view === 'vault' }"
|
||||
@click="view = 'vault'"
|
||||
>
|
||||
임시 저장</button
|
||||
><button
|
||||
role="tab"
|
||||
:aria-selected="view === 'inbox'"
|
||||
:class="{ active: view === 'inbox' }"
|
||||
@click="view = 'inbox'"
|
||||
>
|
||||
받은 항목
|
||||
</button>
|
||||
</div>
|
||||
<form class="compact-search" @submit.prevent="act(refreshLists)">
|
||||
<input
|
||||
type="search"
|
||||
v-model="search"
|
||||
placeholder="링크, 파일, 메모 검색"
|
||||
aria-label="계정 자료 검색"
|
||||
/><button class="button small">검색</button>
|
||||
</form>
|
||||
<button v-if="selected.length" class="button bulk-delete" @click="remove(selected)">
|
||||
<Trash2 :size="16" />선택 삭제
|
||||
</button>
|
||||
<section v-for="group in groups" :key="group.date" class="dated-items">
|
||||
<h2>{{ group.date }} {{ view === 'inbox' ? '받은 항목' : '저장한 항목' }}</h2>
|
||||
<article v-for="item in group.items" :key="item.accessId" class="item-card compact-item">
|
||||
<div class="item-heading">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="selected"
|
||||
:value="item.accessId"
|
||||
:aria-label="`${item.file?.name ?? item.url} 선택`"
|
||||
/><span>{{ item.file ? '파일' : '링크' }}</span
|
||||
><span v-if="view === 'inbox'">{{ item.peerName }}</span
|
||||
><time>{{ date(item.createdAt) }}</time>
|
||||
</div>
|
||||
<strong v-if="item.note" class="item-note">{{ item.note }}</strong>
|
||||
<div v-if="item.file" class="file-item-body">
|
||||
<FileIcon :size="24" />
|
||||
<div>
|
||||
<strong>{{ item.file.name }}</strong
|
||||
><span>{{ size(item.file.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="item-body">{{ item.url }}</p>
|
||||
<form v-if="editing === item.accessId" class="note-editor" @submit.prevent="saveNote(item)">
|
||||
<input v-model="noteDraft" maxlength="200" aria-label="한 줄 메모 수정" /><button
|
||||
class="button small"
|
||||
:disabled="busy"
|
||||
>
|
||||
저장</button
|
||||
><button type="button" class="text-button" @click="editing = null">취소</button>
|
||||
</form>
|
||||
<div class="item-footer">
|
||||
<span>{{ date(item.expiresAt) }} 삭제</span>
|
||||
<div>
|
||||
<button class="button small" @click="emit('send-item', item)">
|
||||
<Send :size="14" />기기로 보내기</button
|
||||
><button v-if="item.file" class="button small" @click="act(() => download(item))">
|
||||
<Download :size="14" />다운로드</button
|
||||
><template v-else
|
||||
><button class="button small" @click="copy(item)"><Copy :size="14" />복사</button
|
||||
><a
|
||||
class="button small"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click="recordLinkOpen(item)"
|
||||
><ArrowUpRight :size="14" />열기</a
|
||||
></template
|
||||
><button
|
||||
v-if="view === 'vault'"
|
||||
class="icon-button"
|
||||
aria-label="메모 수정"
|
||||
@click="
|
||||
editing = item.accessId;
|
||||
noteDraft = item.note ?? '';
|
||||
"
|
||||
>
|
||||
<Pencil :size="15" /></button
|
||||
><button
|
||||
class="icon-button"
|
||||
aria-label="계정 목록에서 삭제"
|
||||
@click="remove([item.accessId])"
|
||||
>
|
||||
<Trash2 :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<div v-if="!items.length" class="list-empty">
|
||||
<Inbox :size="28" />
|
||||
<h3>{{ view === 'inbox' ? '아직 받은 항목이 없어요.' : '임시 보관함이 비어 있어요.' }}</h3>
|
||||
<p>
|
||||
{{
|
||||
view === 'inbox'
|
||||
? '로그인한 상태로 기기에서 받으면 여기에 남습니다.'
|
||||
: '링크나 파일에 한 줄 메모를 더해 담아두세요.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<button v-if="nextCursor" class="button load-more" @click="loadMoreAccount">더 보기</button>
|
||||
<p class="scope-note">
|
||||
저장 용량 {{ size(storage.usedBytes) }} / {{ size(storage.limitBytes) }}
|
||||
</p>
|
||||
</section>
|
||||
<div v-if="compose" class="modal-backdrop" @click.self="!saveTransfer && (compose = false)">
|
||||
<section
|
||||
class="modal vault-save-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="보관함에 담기"
|
||||
>
|
||||
<header>
|
||||
<h2>보관함에 담기</h2>
|
||||
<button
|
||||
class="icon-button"
|
||||
:disabled="Boolean(saveTransfer) || busy"
|
||||
@click="compose = false"
|
||||
aria-label="보관함 저장 닫기"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<div v-if="!saveKind" class="save-kind-choices">
|
||||
<button class="button" @click="saveKind = 'url'"><Link :size="22" />링크 저장</button
|
||||
><button class="button" @click="saveKind = 'file'"><FileIcon :size="22" />파일 저장</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<label class="vault-note-label"
|
||||
>제목 / 한 줄 메모 (선택)<input
|
||||
v-model="note"
|
||||
maxlength="200"
|
||||
aria-label="한 줄 메모"
|
||||
placeholder="예: 나중에 읽을 글"
|
||||
:disabled="Boolean(saveTransfer) || busy" /></label
|
||||
><SendComposer
|
||||
target-name="임시 보관함"
|
||||
target-key="vault-save"
|
||||
:save-mode="true"
|
||||
:content-mode="saveKind"
|
||||
:read-clipboard-on-selection="true"
|
||||
:busy="busy"
|
||||
:transfer="saveTransfer"
|
||||
:progress="
|
||||
Object.entries(uploadProgress)
|
||||
.filter(([key]) => key.startsWith(saveTransfer?.id + ':'))
|
||||
.map(([, value]) => value)
|
||||
"
|
||||
@links="save({ urls: $event })"
|
||||
@files="save({ files: $event })"
|
||||
@cancel="cancelSave"
|
||||
/>
|
||||
<button
|
||||
class="text-button"
|
||||
:disabled="Boolean(saveTransfer) || busy"
|
||||
@click="saveKind = null"
|
||||
>
|
||||
다른 종류 선택
|
||||
</button></template
|
||||
>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="preferencesOpen" class="modal-backdrop" @click.self="preferencesOpen = false">
|
||||
<section class="modal" role="dialog" aria-modal="true" aria-label="계정 설정">
|
||||
<header>
|
||||
<h2>계정 설정</h2>
|
||||
<button class="icon-button" @click="preferencesOpen = false" aria-label="계정 설정 닫기">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<p>{{ account.email }}</p>
|
||||
<select v-model="securityAction" aria-label="계정 정보 변경">
|
||||
<option value="none">변경할 항목 선택</option>
|
||||
<option value="password">비밀번호 변경</option>
|
||||
<option value="email">이메일 변경</option>
|
||||
</select>
|
||||
<form v-if="securityAction !== 'none'" @submit.prevent="securitySubmit">
|
||||
<label
|
||||
>현재 비밀번호<input
|
||||
type="password"
|
||||
v-model="currentPassword"
|
||||
required
|
||||
autocomplete="current-password" /></label
|
||||
><template v-if="securityAction === 'password'"
|
||||
><label
|
||||
>새 비밀번호<input
|
||||
type="password"
|
||||
v-model="newPassword"
|
||||
:minlength="passwordMinLength"
|
||||
required
|
||||
autocomplete="new-password" /></label
|
||||
><label
|
||||
>새 비밀번호 확인<input
|
||||
type="password"
|
||||
v-model="newConfirmation"
|
||||
:minlength="passwordMinLength"
|
||||
required
|
||||
autocomplete="new-password" /></label></template
|
||||
><label v-else>새 이메일<input type="email" v-model="newEmail" required /></label
|
||||
><button class="button primary" :disabled="busy">변경</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="notice" class="toast" role="status"><Check :size="16" />{{ notice }}</div>
|
||||
<div class="undo-stack">
|
||||
<div
|
||||
v-for="operation in undo.filter((v) => v.undoUntil > now)"
|
||||
:key="operation.operationId"
|
||||
class="undo-toast"
|
||||
>
|
||||
<span>삭제됨</span
|
||||
><button @click="restore(operation)"><RotateCcw :size="16" />실행 취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
942
apps/web/src/App.vue
Normal file
942
apps/web/src/App.vue
Normal file
@@ -0,0 +1,942 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ArrowRight,
|
||||
Plus,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Laptop,
|
||||
QrCode,
|
||||
Link,
|
||||
Check,
|
||||
X,
|
||||
Copy,
|
||||
Settings,
|
||||
Sun,
|
||||
Moon,
|
||||
Radio,
|
||||
Send,
|
||||
Inbox,
|
||||
ShieldCheck,
|
||||
ChevronDown,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
ArrowDownLeft,
|
||||
CircleHelp,
|
||||
File as FileIcon,
|
||||
Trash2,
|
||||
Download,
|
||||
RotateCcw,
|
||||
EllipsisVertical,
|
||||
} from '@lucide/vue';
|
||||
import QRCode from 'qrcode';
|
||||
import AccountPanel from './AccountPanel.vue';
|
||||
import SendComposer from './SendComposer.vue';
|
||||
import { createDeliveryReceiver } from './deliveryReceiver';
|
||||
import { initialTheme, applyTheme as setTheme } from './theme';
|
||||
import {
|
||||
device,
|
||||
account,
|
||||
connected,
|
||||
peers,
|
||||
requests,
|
||||
items,
|
||||
spaces,
|
||||
error,
|
||||
bootstrap,
|
||||
api,
|
||||
refresh,
|
||||
send,
|
||||
showError,
|
||||
requestAction,
|
||||
patchDevice,
|
||||
markRead,
|
||||
uploadProgress,
|
||||
sendFiles,
|
||||
cancelTransfer,
|
||||
download,
|
||||
} from './client';
|
||||
const tab = ref('devices'),
|
||||
selected = ref(null),
|
||||
loading = ref(true),
|
||||
busy = ref(false),
|
||||
editName = ref(false),
|
||||
nameDraft = ref(''),
|
||||
notice = ref(''),
|
||||
modal = ref(null),
|
||||
joinCode = ref(''),
|
||||
approvalRequired = ref(false),
|
||||
invite = ref(null),
|
||||
qr = ref(''),
|
||||
now = ref(Date.now());
|
||||
const localOnlyQr = /^(localhost|127(?:\.\d+){3}|\[::1\])$/.test(location.hostname);
|
||||
const theme = ref(initialTheme());
|
||||
const media = matchMedia('(prefers-color-scheme: dark)');
|
||||
function applyTheme() {
|
||||
setTheme(theme.value);
|
||||
}
|
||||
function toggleTheme() {
|
||||
theme.value = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
|
||||
applyTheme();
|
||||
}
|
||||
const incoming = computed(() =>
|
||||
requests.value.filter(
|
||||
(r) => r.direction === 'received' && r.status === 'pending' && r.expiresAt > now.value,
|
||||
),
|
||||
);
|
||||
const receiving = computed(() =>
|
||||
requests.value.filter(
|
||||
(r) =>
|
||||
r.direction === 'received' &&
|
||||
r.status === 'accepted' &&
|
||||
r.transferStatus !== 'stored' &&
|
||||
r.transferStatus !== 'failed',
|
||||
),
|
||||
);
|
||||
const receiver = createDeliveryReceiver({ download, markRead, onError: showError });
|
||||
const blockedLinks = receiver.blockedLinks;
|
||||
watch([requests, items], ([currentRequests, currentItems]) => {
|
||||
receiver.reconcile(currentRequests, currentItems);
|
||||
});
|
||||
onUnmounted(receiver.dispose);
|
||||
async function receiveAction(r, action) {
|
||||
if (busy.value) return;
|
||||
if (action === 'accept') receiver.reserve(r);
|
||||
await act(async () => {
|
||||
try {
|
||||
await requestAction(r.id, action);
|
||||
} catch (error) {
|
||||
if (action === 'accept') receiver.abort(r.id);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
const deviceMenu = ref(null);
|
||||
function isHost(id) {
|
||||
return Boolean(id && spaces.value.some((s) => s.creatorDeviceId === id));
|
||||
}
|
||||
function managedSpaces(id) {
|
||||
return spaces.value.filter(
|
||||
(s) => s.creatorDeviceId === device.value?.id && s.members.some((m) => m.device.id === id),
|
||||
);
|
||||
}
|
||||
const connectionCards = computed(() => {
|
||||
const cards = new Map(peers.value.map((peer) => [peer.id, { peer, pending: false }]));
|
||||
for (const space of spaces.value) {
|
||||
if (space.creatorDeviceId !== device.value?.id) continue;
|
||||
for (const member of space.members) {
|
||||
if (member.status === 'pending' && !cards.has(member.device.id))
|
||||
cards.set(member.device.id, {
|
||||
peer: { ...member.device, sources: ['space'] },
|
||||
pending: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...cards.values()];
|
||||
});
|
||||
async function toggleDeviceMenu(id) {
|
||||
if (deviceMenu.value === id) {
|
||||
deviceMenu.value = null;
|
||||
return;
|
||||
}
|
||||
deviceMenu.value = id;
|
||||
await nextTick();
|
||||
document.getElementById(`device-menu-${id}`)?.querySelector('button')?.focus();
|
||||
}
|
||||
function closeDeviceMenu(restore = false) {
|
||||
const id = deviceMenu.value;
|
||||
deviceMenu.value = null;
|
||||
if (restore && id) document.getElementById(`device-menu-toggle-${id}`)?.focus();
|
||||
}
|
||||
function outsideDeviceMenu(event) {
|
||||
if (!event.target.closest('[data-device-menu]')) closeDeviceMenu();
|
||||
}
|
||||
function escapeDeviceMenu(event) {
|
||||
if (event.key === 'Escape' && deviceMenu.value) {
|
||||
event.preventDefault();
|
||||
closeDeviceMenu(true);
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerdown', outsideDeviceMenu);
|
||||
document.addEventListener('keydown', escapeDeviceMenu);
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('pointerdown', outsideDeviceMenu);
|
||||
document.removeEventListener('keydown', escapeDeviceMenu);
|
||||
});
|
||||
const active = computed(() =>
|
||||
requests.value.filter(
|
||||
(r) =>
|
||||
r.direction === 'sent' &&
|
||||
(r.status === 'pending' ||
|
||||
(r.status === 'accepted' && ['waiting', 'uploading'].includes(r.transferStatus))),
|
||||
),
|
||||
);
|
||||
const currentTransfer = computed(() =>
|
||||
active.value.find((r) => r.recipientDevice?.id === selected.value?.id),
|
||||
);
|
||||
async function cancelCurrentTransfer() {
|
||||
const request = currentTransfer.value;
|
||||
if (!request) return;
|
||||
await act(() => cancelTransfer(request.id));
|
||||
}
|
||||
const visibleUploadProgress = computed(() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(uploadProgress.value).filter(
|
||||
([, p]) => !p.accountId || p.accountId === account.value?.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
const canInvite = computed(
|
||||
() =>
|
||||
!spaces.value.length ||
|
||||
(invite.value?.space.id === spaces.value[0]?.id && invite.value.space.expiresAt > now.value),
|
||||
);
|
||||
const sendOpen = ref(false),
|
||||
sendDialog = ref(null);
|
||||
let beforeSendFocus;
|
||||
watch(sendOpen, async (open) => {
|
||||
if (open) {
|
||||
beforeSendFocus = document.activeElement;
|
||||
await nextTick();
|
||||
sendDialog.value?.querySelector('button:not(:disabled)')?.focus();
|
||||
} else if (beforeSendFocus?.isConnected) beforeSendFocus.focus();
|
||||
});
|
||||
function trapSendFocus(event) {
|
||||
const buttons = [
|
||||
...sendDialog.value.querySelectorAll(
|
||||
'button:not(:disabled), input:not(:disabled), textarea:not(:disabled), a[href]',
|
||||
),
|
||||
].filter((node) => node.getClientRects().length);
|
||||
const first = buttons[0],
|
||||
last = buttons.at(-1);
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
async function closeSend() {
|
||||
if (busy.value) return;
|
||||
const request = currentTransfer.value;
|
||||
if (request)
|
||||
await act(async () => {
|
||||
await cancelTransfer(request.id);
|
||||
sendOpen.value = false;
|
||||
selected.value = null;
|
||||
itemToSend.value = null;
|
||||
});
|
||||
else {
|
||||
sendOpen.value = false;
|
||||
selected.value = null;
|
||||
itemToSend.value = null;
|
||||
}
|
||||
}
|
||||
watch(requests, (current, previous) => {
|
||||
for (const request of current)
|
||||
if (
|
||||
request.direction === 'sent' &&
|
||||
request.transferStatus === 'stored' &&
|
||||
previous.find((old) => old.id === request.id)?.transferStatus !== 'stored' &&
|
||||
request.recipientDevice?.id === selected.value?.id
|
||||
) {
|
||||
sendOpen.value = false;
|
||||
selected.value = null;
|
||||
itemToSend.value = null;
|
||||
notify('전송했습니다.');
|
||||
}
|
||||
});
|
||||
const selectionEpoch = ref(0);
|
||||
watch(spaces, (current, previous) => {
|
||||
const hosted = current.find((space) => space.creatorDeviceId === device.value?.id);
|
||||
const old = previous.find((space) => space.id === hosted?.id);
|
||||
if (
|
||||
hosted &&
|
||||
old &&
|
||||
hosted.members.some(
|
||||
(member) =>
|
||||
member.device.id !== device.value?.id &&
|
||||
!old.members.some((before) => before.device.id === member.device.id),
|
||||
)
|
||||
) {
|
||||
if (modal.value === 'connect') modal.value = null;
|
||||
notify('기기가 방에 참여했습니다.');
|
||||
}
|
||||
if (!current.length && previous.length) {
|
||||
selected.value = null;
|
||||
invite.value = null;
|
||||
modal.value = null;
|
||||
notify('방에서 나왔습니다.');
|
||||
}
|
||||
});
|
||||
const vaultItems = ref([]),
|
||||
vaultCursor = ref(null),
|
||||
forwardedItem = ref(null),
|
||||
itemToSend = ref(null);
|
||||
async function loadVaultChoices() {
|
||||
if (!account.value) return;
|
||||
const owner = account.value.id;
|
||||
await act(async () => {
|
||||
const result = await api('/vault/contents');
|
||||
if (account.value?.id !== owner) return;
|
||||
vaultItems.value = result.items;
|
||||
vaultCursor.value = result.nextCursor;
|
||||
});
|
||||
}
|
||||
async function moreVault() {
|
||||
const owner = account.value?.id;
|
||||
await act(async () => {
|
||||
const result = await api(`/vault/contents?cursor=${encodeURIComponent(vaultCursor.value)}`);
|
||||
if (account.value?.id !== owner) return;
|
||||
vaultItems.value.push(...result.items);
|
||||
vaultCursor.value = result.nextCursor;
|
||||
});
|
||||
}
|
||||
function prepareVault(item) {
|
||||
itemToSend.value = item;
|
||||
selected.value = null;
|
||||
sendOpen.value = false;
|
||||
forwardedItem.value = null;
|
||||
tab.value = 'devices';
|
||||
notify('받을 기기를 선택해 주세요.');
|
||||
}
|
||||
async function forwardVault(item) {
|
||||
if (!selected.value) {
|
||||
itemToSend.value = item;
|
||||
tab.value = 'devices';
|
||||
notify('받을 기기를 선택해 주세요.');
|
||||
return;
|
||||
}
|
||||
const owner = account.value?.id;
|
||||
await act(async () => {
|
||||
await api(
|
||||
`/vault/${item.accessId}/send`,
|
||||
'POST',
|
||||
{ targetDeviceId: selected.value.id },
|
||||
crypto.randomUUID(),
|
||||
);
|
||||
if (account.value?.id !== owner) return;
|
||||
forwardedItem.value = item;
|
||||
sendOpen.value = true;
|
||||
itemToSend.value = null;
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
watch(
|
||||
() => account.value?.id,
|
||||
() => {
|
||||
vaultItems.value = [];
|
||||
vaultCursor.value = null;
|
||||
itemToSend.value = null;
|
||||
forwardedItem.value = null;
|
||||
},
|
||||
);
|
||||
let timer;
|
||||
onMounted(async () => {
|
||||
applyTheme();
|
||||
media.addEventListener('change', applyTheme);
|
||||
timer = setInterval(() => {
|
||||
now.value = Date.now();
|
||||
}, 1000);
|
||||
try {
|
||||
await bootstrap();
|
||||
if (/(?:^|&)(verify|reset-password|verify-email-change)=/.test(location.hash.slice(1)))
|
||||
tab.value = 'vault';
|
||||
const token = new URLSearchParams(location.hash.slice(1)).get('join');
|
||||
if (token) {
|
||||
history.replaceState(null, '', location.pathname);
|
||||
await api('/spaces/join', 'POST', { token });
|
||||
await refresh();
|
||||
notify('연결 공간에 참여했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
const onFocus = () => void refresh().catch(showError);
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') onFocus();
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
clearInterval(timer);
|
||||
media.removeEventListener('change', applyTheme);
|
||||
});
|
||||
function notify(message) {
|
||||
notice.value = message;
|
||||
setTimeout(() => {
|
||||
if (notice.value === message) notice.value = '';
|
||||
}, 4000);
|
||||
}
|
||||
async function act(fn) {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function openDeviceSettings() {
|
||||
nameDraft.value = device.value?.name ?? '';
|
||||
modal.value = 'device';
|
||||
await nextTick();
|
||||
document.querySelector('.device-settings-form input')?.focus();
|
||||
}
|
||||
async function saveName() {
|
||||
await act(async () => {
|
||||
await patchDevice({ name: nameDraft.value });
|
||||
editName.value = false;
|
||||
modal.value = null;
|
||||
notify('기기 이름을 변경했습니다.');
|
||||
});
|
||||
}
|
||||
function selectPeer(peer) {
|
||||
closeDeviceMenu();
|
||||
selected.value = peer;
|
||||
sendOpen.value = true;
|
||||
selectionEpoch.value++;
|
||||
forwardedItem.value = null;
|
||||
}
|
||||
async function submitLinks(urls) {
|
||||
await act(async () => {
|
||||
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
|
||||
forwardedItem.value = null;
|
||||
itemToSend.value = null;
|
||||
await send(selected.value.id, 'url', { urls });
|
||||
});
|
||||
}
|
||||
async function submitFiles(files) {
|
||||
await act(async () => {
|
||||
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
|
||||
forwardedItem.value = null;
|
||||
itemToSend.value = null;
|
||||
await sendFiles(selected.value.id, files);
|
||||
});
|
||||
}
|
||||
async function createSpace() {
|
||||
await act(async () => {
|
||||
invite.value = await api('/spaces', 'POST', { approvalRequired: approvalRequired.value });
|
||||
qr.value = await QRCode.toDataURL(`${location.origin}/#join=${invite.value.token}`, {
|
||||
width: 208,
|
||||
margin: 1,
|
||||
color: { dark: '#111111', light: '#ffffff' },
|
||||
errorCorrectionLevel: 'M',
|
||||
});
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
async function joinSpace() {
|
||||
await act(async () => {
|
||||
await api('/spaces/join', 'POST', { code: joinCode.value.replace(/[\s-]/g, '').toUpperCase() });
|
||||
joinCode.value = '';
|
||||
await refresh();
|
||||
modal.value = null;
|
||||
notify('방에 참여했습니다.');
|
||||
});
|
||||
}
|
||||
async function copyValue(value, item) {
|
||||
await act(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
throw Error('복사하지 못했습니다. 내용을 선택해 직접 복사해 주세요.');
|
||||
}
|
||||
if (item) await markRead(item);
|
||||
notify('복사했습니다.');
|
||||
});
|
||||
}
|
||||
function iconFor(name) {
|
||||
return /phone|android|galaxy/i.test(name)
|
||||
? Smartphone
|
||||
: /mac|laptop/i.test(name)
|
||||
? Laptop
|
||||
: Monitor;
|
||||
}
|
||||
function time(value) {
|
||||
return new Intl.DateTimeFormat('ko', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(value);
|
||||
}
|
||||
function size(bytes) {
|
||||
return bytes >= 1073741824
|
||||
? (bytes / 1073741824).toFixed(1) + ' GiB'
|
||||
: bytes >= 1048576
|
||||
? (bytes / 1048576).toFixed(1) + ' MiB'
|
||||
: bytes >= 1024
|
||||
? (bytes / 1024).toFixed(1) + ' KiB'
|
||||
: bytes + ' B';
|
||||
}
|
||||
async function spaceAction(space, action, memberId) {
|
||||
await act(async () => {
|
||||
await api(
|
||||
`/spaces/${space.id}${memberId ? `/members/${memberId}${action === 'approve' ? '/approve' : ''}` : ''}`,
|
||||
action === 'approve' ? 'POST' : 'DELETE',
|
||||
action === 'approve' ? {} : undefined,
|
||||
);
|
||||
if (invite.value?.space.id === space.id && !memberId) invite.value = null;
|
||||
await refresh();
|
||||
closeDeviceMenu();
|
||||
if (memberId && action === 'remove') {
|
||||
if (selected.value?.id === memberId && !peers.value.some((p) => p.id === memberId))
|
||||
selected.value = null;
|
||||
notify('연결 공간에서 내보냈습니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell" :data-online="connected">
|
||||
<header class="site-header">
|
||||
<div class="header-brand">
|
||||
<button class="wordmark" @click="tab = 'devices'" aria-label="hop. 홈">
|
||||
hop<span>.</span>
|
||||
</button>
|
||||
<span class="header-tagline">기기 사이를 가볍게.</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="icon-button theme-toggle"
|
||||
:aria-label="theme === 'light' ? '다크 테마로 전환' : '라이트 테마로 전환'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<Moon v-if="theme === 'light'" :size="19" /><Sun v-else :size="19" /></button
|
||||
><button class="login-button" @click="tab = 'vault'">
|
||||
{{ account ? '보관함' : '로그인' }} <ArrowUpRight :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<p v-if="!connected && !loading" class="reconnect-note" role="status">
|
||||
연결을 복구하고 있어요.
|
||||
</p>
|
||||
<div v-if="loading" class="loading-state">
|
||||
<LoaderCircle class="spin" :size="24" />
|
||||
<p>기기를 연결하고 있어요.</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<Teleport to="body">
|
||||
<section
|
||||
v-if="receiving.length || blockedLinks.length"
|
||||
class="incoming-section"
|
||||
aria-label="수신 알림"
|
||||
aria-live="polite"
|
||||
>
|
||||
<article
|
||||
v-for="r in receiving"
|
||||
:key="`receiving-${r.id}`"
|
||||
class="incoming-card receiving-card"
|
||||
>
|
||||
<span class="incoming-icon"><LoaderCircle class="spin" :size="20" /></span>
|
||||
<div class="incoming-info">
|
||||
<strong>{{ r.senderDevice?.name }}에서 받는 중</strong>
|
||||
<p>
|
||||
{{
|
||||
r.transferStatus === 'uploading'
|
||||
? '파일을 업로드하고 있어요.'
|
||||
: '수락했습니다. 발신 기기에서 전달을 준비하고 있어요.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<article
|
||||
v-for="item in blockedLinks"
|
||||
:key="item.accessId"
|
||||
class="incoming-card delivery-blocked"
|
||||
>
|
||||
<div class="incoming-info">
|
||||
<strong>브라우저가 새 탭 열기를 제한했어요.</strong>
|
||||
<p>아래 링크를 누르면 바로 열립니다.</p>
|
||||
</div>
|
||||
<a
|
||||
class="button primary small"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click="receiver.opened(item)"
|
||||
>링크 열기 <ArrowUpRight :size="15"
|
||||
/></a>
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="안내 닫기"
|
||||
@click="blockedLinks = blockedLinks.filter((i) => i.accessId !== item.accessId)"
|
||||
>
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
<div v-if="incoming.length" class="receive-backdrop">
|
||||
<article
|
||||
v-for="r in incoming.slice(0, 1)"
|
||||
:key="r.id"
|
||||
class="incoming-card consent-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="전송 수락 요청"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="incoming-icon"><ArrowDownLeft :size="22" /></div>
|
||||
<div class="incoming-info">
|
||||
<strong
|
||||
>{{ r.senderDevice?.name }} <small>#{{ r.senderDevice?.code }}</small></strong
|
||||
>
|
||||
<p>
|
||||
{{ r.kind === 'file' ? '파일' : '링크' }} · {{ r.count }}개 ·
|
||||
{{ r.byteSize.toLocaleString() }}바이트
|
||||
<span>· {{ Math.max(0, Math.ceil((r.expiresAt - now) / 1000)) }}초 남음</span>
|
||||
</p>
|
||||
</div>
|
||||
<ul v-if="r.files" class="request-files">
|
||||
<li v-for="file in r.files" :key="file.id">
|
||||
{{ file.name }} · {{ size(file.size) }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="incoming-actions">
|
||||
<button
|
||||
class="button primary small"
|
||||
:disabled="busy"
|
||||
@click="receiveAction(r, 'accept')"
|
||||
>
|
||||
<Check :size="16" /> 수락</button
|
||||
><button class="button small" :disabled="busy" @click="receiveAction(r, 'reject')">
|
||||
거절</button
|
||||
><button
|
||||
class="text-button muted"
|
||||
:disabled="busy"
|
||||
@click="receiveAction(r, 'block')"
|
||||
>
|
||||
차단
|
||||
</button>
|
||||
</div>
|
||||
<p class="request-note">기기 이름은 신원이 인증된 표시가 아닙니다.</p>
|
||||
</article>
|
||||
</div>
|
||||
</Teleport>
|
||||
<template v-if="tab === 'devices'">
|
||||
<section class="devices-stage">
|
||||
<div class="stage-heading"><h1>어디로 보낼까요?</h1></div>
|
||||
<section class="nearby-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>
|
||||
{{ spaces.length ? '방 안의 기기' : '주변 기기' }}
|
||||
<span>{{ connectionCards.length.toString().padStart(2, '0') }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<button class="icon-button" @click="act(refresh)" aria-label="기기 목록 새로고침">
|
||||
<RefreshCw :size="17" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!connectionCards.length" class="empty-device-hint">
|
||||
{{
|
||||
device?.discoverable
|
||||
? '상대도 hop.을 열거나 QR로 연결해 주세요.'
|
||||
: '아래 내 기기 설정에서 발견 허용을 켜 주세요.'
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
class="peer-grid"
|
||||
:class="`device-count-${connectionCards.length + (canInvite ? 1 : 0)}`"
|
||||
v-if="connectionCards.length || canInvite"
|
||||
>
|
||||
<div
|
||||
v-for="card in connectionCards"
|
||||
:key="card.peer.id"
|
||||
class="peer-entry"
|
||||
data-device-menu
|
||||
>
|
||||
<button
|
||||
class="peer-card"
|
||||
:class="{ selected: selected?.id === card.peer.id, pending: card.pending }"
|
||||
:disabled="card.pending"
|
||||
@click="selectPeer(card.peer)"
|
||||
>
|
||||
<span class="peer-top"
|
||||
><component :is="iconFor(card.peer.name)" :size="30" /><span
|
||||
class="peer-live"
|
||||
:class="{ hasMenu: managedSpaces(card.peer.id).length }"
|
||||
><template v-if="card.pending">승인 대기</template
|
||||
><template v-else><i></i>온라인</template></span
|
||||
></span
|
||||
><strong>{{ card.peer.name }}</strong
|
||||
><span class="peer-code">#{{ card.peer.code }}</span
|
||||
><span class="peer-bottom"
|
||||
><span
|
||||
>{{ card.peer.sources.includes('network') ? '같은 네트워크' : '연결 공간' }}
|
||||
<span v-if="isHost(card.peer.id)" class="host-badge">HOST</span></span
|
||||
><span v-if="!card.pending" class="peer-select"
|
||||
><Check v-if="selected?.id === card.peer.id" :size="14" /><ArrowUpRight
|
||||
v-else
|
||||
:size="14" /></span
|
||||
></span></button
|
||||
><button
|
||||
v-if="managedSpaces(card.peer.id).length"
|
||||
:id="`device-menu-toggle-${card.peer.id}`"
|
||||
class="icon-button device-menu-toggle"
|
||||
:aria-label="`${card.peer.name} 기기 더보기`"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="deviceMenu === card.peer.id"
|
||||
:aria-controls="`device-menu-${card.peer.id}`"
|
||||
@click="toggleDeviceMenu(card.peer.id)"
|
||||
>
|
||||
<EllipsisVertical :size="18" />
|
||||
</button>
|
||||
<div
|
||||
v-if="deviceMenu === card.peer.id"
|
||||
:id="`device-menu-${card.peer.id}`"
|
||||
class="device-menu"
|
||||
role="menu"
|
||||
:aria-label="`${card.peer.name} 기기 관리`"
|
||||
>
|
||||
<template v-for="space in managedSpaces(card.peer.id)" :key="space.id"
|
||||
><button
|
||||
v-if="
|
||||
space.members.some(
|
||||
(m) => m.device.id === card.peer.id && m.status === 'pending',
|
||||
)
|
||||
"
|
||||
role="menuitem"
|
||||
:disabled="busy"
|
||||
@click="spaceAction(space, 'approve', card.peer.id)"
|
||||
>
|
||||
참여 승인</button
|
||||
><button
|
||||
role="menuitem"
|
||||
class="kick-action"
|
||||
:disabled="busy"
|
||||
@click="spaceAction(space, 'remove', card.peer.id)"
|
||||
>
|
||||
내보내기<span v-if="managedSpaces(card.peer.id).length > 1">
|
||||
· 연결 공간 {{ spaces.findIndex((s) => s.id === space.id) + 1 }}</span
|
||||
>
|
||||
</button></template
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="canInvite"
|
||||
class="qr-connect-card"
|
||||
:aria-label="spaces.length ? 'QR 초대 보기' : 'QR · 코드로 연결'"
|
||||
@click="modal = 'connect'"
|
||||
>
|
||||
<QrCode :size="32" /><strong>{{
|
||||
spaces.length ? 'QR 초대 보기' : 'QR로 연결'
|
||||
}}</strong
|
||||
><span>다른 기기 연결하기</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="empty-peers">
|
||||
<div class="orbit-icon"><Radio :size="35" stroke-width="1.2" /></div>
|
||||
<h3>
|
||||
{{ spaces.length ? '기기 참여를 기다리고 있어요.' : '상대도 hop.을 열어주세요.' }}
|
||||
</h3>
|
||||
<p v-if="!spaces.length">다른 네트워크라면 QR이나 코드로 연결하세요.</p>
|
||||
<button
|
||||
v-if="
|
||||
!spaces.length ||
|
||||
(invite?.space.id === spaces[0]?.id && invite.space.expiresAt > now)
|
||||
"
|
||||
class="button room-connect"
|
||||
@click="modal = 'connect'"
|
||||
>
|
||||
<QrCode :size="17" />{{ spaces.length ? 'QR 초대 보기' : 'QR · 코드로 연결' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="spaces.length" class="connection-actions">
|
||||
<button
|
||||
class="text-button room-leave"
|
||||
:disabled="busy"
|
||||
@click="
|
||||
spaceAction(
|
||||
spaces[0],
|
||||
'remove',
|
||||
spaces[0].creatorDeviceId === device?.id ? undefined : device?.id,
|
||||
)
|
||||
"
|
||||
>
|
||||
방 나가기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<template v-else
|
||||
><AccountPanel
|
||||
:initial-view="tab === 'received' ? 'inbox' : 'vault'"
|
||||
@devices="tab = 'devices'"
|
||||
@send-item="prepareVault"
|
||||
/></template>
|
||||
</template>
|
||||
</main>
|
||||
<footer class="site-footer device-footer">
|
||||
<div class="my-device-info" v-if="device">
|
||||
<button
|
||||
class="device-name footer-device-summary"
|
||||
aria-label="내 기기 설정"
|
||||
@click="openDeviceSettings"
|
||||
>
|
||||
<i class="device-state-dot" :class="{ enabled: device.discoverable }"></i
|
||||
><span class="footer-device-name">{{ device.name }}</span
|
||||
><small>· {{ device.discoverable ? '발견 허용' : '발견 꺼짐' }}</small
|
||||
><span class="footer-device-code">#{{ device.code }}</span
|
||||
><span v-if="isHost(device.id)" class="host-badge">HOST</span><Settings :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<span class="footer-brand">by noname.company</span>
|
||||
</footer>
|
||||
<Teleport to="body"
|
||||
><div
|
||||
v-if="sendOpen && selected"
|
||||
class="modal-backdrop send-backdrop"
|
||||
@click.self="closeSend"
|
||||
@keydown.esc.stop.prevent="closeSend"
|
||||
>
|
||||
<section
|
||||
ref="sendDialog"
|
||||
class="modal send-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="`${selected.name}에 보내기`"
|
||||
@keydown.tab="trapSendFocus"
|
||||
>
|
||||
<header>
|
||||
<h2>{{ selected.name }}에 보내기</h2>
|
||||
<button
|
||||
class="icon-button"
|
||||
:disabled="busy"
|
||||
@click="closeSend"
|
||||
aria-label="전송 화면 닫기"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<SendComposer
|
||||
:target-name="selected?.name"
|
||||
:target-key="selected?.id"
|
||||
:clipboard-key="selectionEpoch"
|
||||
:read-clipboard-on-selection="true"
|
||||
:busy="busy"
|
||||
:transfer="currentTransfer"
|
||||
:progress="
|
||||
Object.entries(visibleUploadProgress)
|
||||
.filter(([key]) => key.startsWith(currentTransfer?.id + ':'))
|
||||
.map(([, value]) => value)
|
||||
"
|
||||
:disabled="!connected"
|
||||
@cancel="cancelCurrentTransfer"
|
||||
@files="submitFiles"
|
||||
@links="submitLinks"
|
||||
:transfer-source="forwardedItem"
|
||||
:initial-item="itemToSend"
|
||||
:vault-enabled="Boolean(account)"
|
||||
:vault-owner="account?.id"
|
||||
:vault-items="vaultItems"
|
||||
:vault-cursor="vaultCursor"
|
||||
@vault-open="loadVaultChoices"
|
||||
@vault-more="moreVault"
|
||||
@vault="forwardVault"
|
||||
>
|
||||
</SendComposer>
|
||||
</section></div
|
||||
></Teleport>
|
||||
<div v-if="notice" class="toast" role="status"><Check :size="17" />{{ notice }}</div>
|
||||
<div v-if="error" class="error-toast" role="alert">
|
||||
<span>{{ error }}</span
|
||||
><button class="icon-button" @click="error = ''" aria-label="오류 닫기">
|
||||
<X :size="17" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="modal" class="modal-backdrop" @click.self="modal = null" @keydown.esc="modal = null">
|
||||
<section
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="modal === 'connect' ? 'QR·코드 연결' : '설정'"
|
||||
>
|
||||
<header>
|
||||
<h2>{{ modal === 'connect' ? 'QR · 코드로 연결' : '내 기기 설정' }}</h2>
|
||||
<button class="icon-button" @click="modal = null" aria-label="대화상자 닫기">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<template v-if="modal === 'connect'"
|
||||
><p class="modal-subtitle">
|
||||
네트워크가 달라도 연결할 수 있어요.<br />연결한 뒤에도 전송마다 수락이 필요합니다.
|
||||
</p>
|
||||
<div v-if="invite && invite.space.expiresAt > now" class="invite-display">
|
||||
<img :src="qr" alt="임시 연결 공간 초대 QR 코드" width="208" height="208" />
|
||||
<p v-if="localOnlyQr" class="local-qr-note">
|
||||
현재 QR은 이 PC의 개발 주소입니다. 휴대폰 접속은 아직 설정하지 않았어요. 같은 PC의
|
||||
다른 브라우저에서 아래 코드를 입력해 주세요.
|
||||
</p>
|
||||
<div class="invite-code">{{ invite.code.slice(0, 4) }} {{ invite.code.slice(4) }}</div>
|
||||
<button class="text-button" @click="copyValue(invite.code)">
|
||||
<Copy :size="14" />코드 복사
|
||||
</button>
|
||||
<p>
|
||||
{{ Math.max(0, Math.ceil((invite.space.expiresAt - now) / 60000)) }}분 동안 · 최대
|
||||
10대
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="!spaces.length" class="create-space">
|
||||
<label class="checkbox-label"
|
||||
><input type="checkbox" v-model="approvalRequired" />참여할 때 내 승인 받기</label
|
||||
><button class="button primary" @click="createSpace" :disabled="busy">
|
||||
<Plus :size="18" />연결 공간 만들기
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!spaces.length" class="modal-divider"><span>또는 코드로 참여</span></div>
|
||||
<form v-if="!spaces.length" @submit.prevent="joinSpace" class="join-form">
|
||||
<input
|
||||
v-model="joinCode"
|
||||
aria-label="연결 코드"
|
||||
placeholder="8자리 연결 코드"
|
||||
maxlength="10"
|
||||
autocomplete="off"
|
||||
autocapitalize="characters"
|
||||
/><button
|
||||
class="button"
|
||||
type="submit"
|
||||
:disabled="busy || joinCode.replace(/[\s-]/g, '').length !== 8"
|
||||
>
|
||||
참여 <ArrowRight :size="16" />
|
||||
</button></form
|
||||
></template>
|
||||
<template v-else
|
||||
><form class="name-form device-settings-form" @submit.prevent="saveName">
|
||||
<label
|
||||
>기기 이름<input
|
||||
v-model="nameDraft"
|
||||
aria-label="기기 이름"
|
||||
maxlength="40"
|
||||
required /></label
|
||||
><button class="button primary" :disabled="busy" aria-label="이름 저장">
|
||||
이름 저장
|
||||
</button>
|
||||
</form>
|
||||
<label class="settings-row discovery-setting"
|
||||
><span>다른 기기에서 발견 허용</span
|
||||
><input
|
||||
type="checkbox"
|
||||
:checked="device?.discoverable"
|
||||
:disabled="busy"
|
||||
@change="act(() => patchDevice({ discoverable: $event.target.checked }))" /></label
|
||||
></template>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
426
apps/web/src/SendComposer.vue
Normal file
426
apps/web/src/SendComposer.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||
import {
|
||||
FileUp,
|
||||
File as FileIcon,
|
||||
Link,
|
||||
Send,
|
||||
X,
|
||||
ClipboardPaste,
|
||||
Inbox,
|
||||
Check,
|
||||
ArrowUpRight,
|
||||
} from '@lucide/vue';
|
||||
import { parseUrls } from '@hop/shared';
|
||||
const props = defineProps({
|
||||
targetName: String,
|
||||
targetKey: String,
|
||||
clipboardKey: Number,
|
||||
busy: Boolean,
|
||||
disabled: Boolean,
|
||||
transfer: Object,
|
||||
progress: Array,
|
||||
readClipboardOnSelection: Boolean,
|
||||
saveMode: Boolean,
|
||||
contentMode: String,
|
||||
transferSource: Object,
|
||||
vaultEnabled: Boolean,
|
||||
vaultItems: Array,
|
||||
vaultCursor: String,
|
||||
initialItem: Object,
|
||||
vaultOwner: String,
|
||||
});
|
||||
const emit = defineEmits(['files', 'links', 'vault', 'vault-open', 'vault-more', 'cancel']);
|
||||
const source = ref('file'),
|
||||
files = ref([]),
|
||||
urlInput = ref(''),
|
||||
clipboardManaged = ref(false),
|
||||
reading = ref(false),
|
||||
feedback = ref(''),
|
||||
selectedVault = ref(null),
|
||||
fileInput = ref(null);
|
||||
let generation = 0;
|
||||
const parsed = computed(() => parseUrls(urlInput.value));
|
||||
const validUrl = computed(
|
||||
() =>
|
||||
parsed.value.urls.length > 0 && parsed.value.urls.length <= 20 && !parsed.value.invalid.length,
|
||||
);
|
||||
const mutable = computed(() => !props.busy && !props.disabled && !props.transfer);
|
||||
const ready = computed(() =>
|
||||
source.value === 'url'
|
||||
? validUrl.value && !reading.value
|
||||
: source.value === 'file'
|
||||
? files.value.length > 0
|
||||
: Boolean(selectedVault.value),
|
||||
);
|
||||
async function loadClipboard(force = false) {
|
||||
if (
|
||||
!props.targetKey ||
|
||||
!props.readClipboardOnSelection ||
|
||||
props.contentMode === 'file' ||
|
||||
!mutable.value ||
|
||||
document.visibilityState === 'hidden'
|
||||
)
|
||||
return;
|
||||
if (
|
||||
!force &&
|
||||
(source.value === 'vault' || files.value.length || (urlInput.value && !clipboardManaged.value))
|
||||
)
|
||||
return;
|
||||
const current = ++generation;
|
||||
reading.value = true;
|
||||
feedback.value = '';
|
||||
try {
|
||||
if (!navigator.clipboard?.readText) throw Error('Unavailable');
|
||||
const result = parseUrls(await navigator.clipboard.readText());
|
||||
if (current !== generation) return;
|
||||
if (result.urls.length && result.urls.length <= 20 && !result.invalid.length) {
|
||||
source.value = 'url';
|
||||
files.value = [];
|
||||
selectedVault.value = null;
|
||||
urlInput.value = result.urls.join('\n');
|
||||
clipboardManaged.value = true;
|
||||
} else if (force || clipboardManaged.value) {
|
||||
urlInput.value = '';
|
||||
clipboardManaged.value = false;
|
||||
if (source.value === 'url') feedback.value = '클립보드에 링크가 없어요. 직접 입력해 주세요.';
|
||||
}
|
||||
} catch {
|
||||
if (current === generation && source.value === 'url')
|
||||
feedback.value = '링크를 직접 붙여넣어 주세요.';
|
||||
} finally {
|
||||
if (current === generation) reading.value = false;
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => [props.targetKey, props.contentMode, props.clipboardKey],
|
||||
(current, previous) => {
|
||||
if (current[0] !== previous?.[0] || current[1] !== previous?.[1]) {
|
||||
generation++;
|
||||
reading.value = false;
|
||||
files.value = [];
|
||||
urlInput.value = '';
|
||||
selectedVault.value = null;
|
||||
clipboardManaged.value = false;
|
||||
feedback.value = '';
|
||||
source.value = props.contentMode === 'url' ? 'url' : 'file';
|
||||
}
|
||||
if (props.initialItem) {
|
||||
source.value = 'vault';
|
||||
selectedVault.value = props.initialItem;
|
||||
} else void loadClipboard(true);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(
|
||||
() => props.transferSource,
|
||||
(item) => {
|
||||
if (item && props.transfer) {
|
||||
source.value = 'vault';
|
||||
selectedVault.value = item;
|
||||
files.value = [];
|
||||
urlInput.value = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => props.transfer,
|
||||
(transfer) => {
|
||||
if (transfer && props.transferSource) {
|
||||
source.value = 'vault';
|
||||
selectedVault.value = props.transferSource;
|
||||
}
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => props.vaultEnabled,
|
||||
(enabled) => {
|
||||
if (!enabled && source.value === 'vault') {
|
||||
source.value = 'file';
|
||||
selectedVault.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => props.vaultItems,
|
||||
(items) => {
|
||||
if (
|
||||
selectedVault.value &&
|
||||
!props.initialItem &&
|
||||
!props.transfer &&
|
||||
!items?.some((item) => item.accessId === selectedVault.value.accessId)
|
||||
)
|
||||
selectedVault.value = null;
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => props.vaultOwner,
|
||||
(owner, previous) => {
|
||||
if (owner !== previous && source.value === 'vault') {
|
||||
selectedVault.value = null;
|
||||
source.value = 'file';
|
||||
}
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
if (!disabled) void loadClipboard();
|
||||
},
|
||||
);
|
||||
const focus = () => void loadClipboard();
|
||||
const visible = () => {
|
||||
if (document.visibilityState === 'visible') focus();
|
||||
};
|
||||
window.addEventListener('focus', focus);
|
||||
document.addEventListener('visibilitychange', visible);
|
||||
onUnmounted(() => {
|
||||
generation++;
|
||||
window.removeEventListener('focus', focus);
|
||||
document.removeEventListener('visibilitychange', visible);
|
||||
});
|
||||
function editUrl() {
|
||||
generation++;
|
||||
reading.value = false;
|
||||
clipboardManaged.value = false;
|
||||
feedback.value = '';
|
||||
}
|
||||
function choose(list) {
|
||||
if (!mutable.value || props.contentMode === 'url' || !list?.length) return;
|
||||
generation++;
|
||||
reading.value = false;
|
||||
files.value = Array.from(list);
|
||||
source.value = 'file';
|
||||
urlInput.value = '';
|
||||
selectedVault.value = null;
|
||||
clipboardManaged.value = false;
|
||||
feedback.value = '';
|
||||
}
|
||||
function pickFiles() {
|
||||
if (mutable.value && fileInput.value) {
|
||||
fileInput.value.value = '';
|
||||
fileInput.value.click();
|
||||
}
|
||||
}
|
||||
function toLink() {
|
||||
if (!mutable.value) return;
|
||||
generation++;
|
||||
reading.value = false;
|
||||
files.value = [];
|
||||
selectedVault.value = null;
|
||||
urlInput.value = '';
|
||||
source.value = 'url';
|
||||
clipboardManaged.value = false;
|
||||
void loadClipboard(true);
|
||||
}
|
||||
function toVault() {
|
||||
if (!mutable.value || !props.vaultEnabled) return;
|
||||
generation++;
|
||||
reading.value = false;
|
||||
files.value = [];
|
||||
urlInput.value = '';
|
||||
selectedVault.value = null;
|
||||
source.value = 'vault';
|
||||
emit('vault-open');
|
||||
}
|
||||
function selectVault(item) {
|
||||
if (mutable.value) selectedVault.value = item;
|
||||
}
|
||||
function pasted(event) {
|
||||
if (!mutable.value) return;
|
||||
if (event.clipboardData?.files.length) {
|
||||
event.preventDefault();
|
||||
choose(event.clipboardData.files);
|
||||
return;
|
||||
}
|
||||
if (props.contentMode === 'file') return;
|
||||
const result = parseUrls(event.clipboardData?.getData('text/plain') ?? '');
|
||||
if (result.urls.length && !result.invalid.length) {
|
||||
event.preventDefault();
|
||||
editUrl();
|
||||
files.value = [];
|
||||
selectedVault.value = null;
|
||||
source.value = 'url';
|
||||
urlInput.value = result.urls.join('\n');
|
||||
}
|
||||
}
|
||||
function submit() {
|
||||
if (props.transfer) {
|
||||
emit('cancel');
|
||||
return;
|
||||
}
|
||||
if (!ready.value || !mutable.value) return;
|
||||
if (source.value === 'url') emit('links', parsed.value.urls);
|
||||
else if (source.value === 'file') emit('files', [...files.value]);
|
||||
else emit('vault', selectedVault.value);
|
||||
}
|
||||
function size(value) {
|
||||
return value >= 1048576
|
||||
? (value / 1048576).toFixed(1) + ' MiB'
|
||||
: value >= 1024
|
||||
? (value / 1024).toFixed(1) + ' KiB'
|
||||
: value + ' B';
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section
|
||||
class="unified-composer single-composer"
|
||||
@paste="pasted"
|
||||
@dragover.prevent
|
||||
@drop.stop.prevent="choose($event.dataTransfer?.files)"
|
||||
>
|
||||
<input
|
||||
v-if="contentMode !== 'url'"
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
class="sr-only"
|
||||
aria-label="보낼 파일 선택"
|
||||
:disabled="!mutable"
|
||||
@change="choose($event.target.files)"
|
||||
/>
|
||||
<div
|
||||
class="content-pane"
|
||||
@dragover.prevent
|
||||
@drop.stop.prevent="choose($event.dataTransfer?.files)"
|
||||
>
|
||||
<template v-if="source === 'url'">
|
||||
<div class="content-heading">
|
||||
<strong><Link :size="18" />보낼 링크</strong
|
||||
><button
|
||||
class="text-button clipboard-load"
|
||||
:disabled="!mutable || reading"
|
||||
@click="loadClipboard(true)"
|
||||
>
|
||||
<ClipboardPaste :size="15" />{{ reading ? '불러오는 중' : '클립보드 가져오기' }}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="urlInput"
|
||||
aria-label="보낼 URL"
|
||||
placeholder="https://example.com"
|
||||
rows="3"
|
||||
:disabled="!mutable"
|
||||
@input="editUrl"
|
||||
class="current-url"
|
||||
/>
|
||||
<p v-if="feedback" class="content-feedback" role="status">{{ feedback }}</p>
|
||||
<p v-else-if="urlInput.trim() && !validUrl" class="content-feedback" role="status">
|
||||
HTTP 또는 HTTPS 링크를 입력해 주세요.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="source === 'file'">
|
||||
<div v-if="!files.length" class="current-file-empty">
|
||||
<FileUp :size="32" /><strong>보낼 파일을 선택하세요.</strong
|
||||
><span>파일을 여기에 놓거나 클릭해 선택</span
|
||||
><button class="button" :disabled="!mutable" @click="pickFiles">파일 선택</button>
|
||||
</div>
|
||||
<template v-else
|
||||
><div class="content-heading">
|
||||
<strong><FileIcon :size="18" />보낼 파일 {{ files.length }}개</strong
|
||||
><button class="text-button" :disabled="!mutable" @click="pickFiles">다시 선택</button>
|
||||
</div>
|
||||
<ul class="selected-files">
|
||||
<li v-for="(file, index) in files" :key="index">
|
||||
<FileIcon :size="15" /><span>{{ file.name }}</span
|
||||
><small>{{ size(file.size) }}</small
|
||||
><button
|
||||
class="icon-button"
|
||||
:disabled="!mutable"
|
||||
:aria-label="`${file.name} 선택 해제`"
|
||||
@click="files.splice(index, 1)"
|
||||
>
|
||||
<X :size="15" />
|
||||
</button>
|
||||
</li></ul
|
||||
></template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="content-heading">
|
||||
<strong><Inbox :size="18" />보관함에서 가져오기</strong>
|
||||
</div>
|
||||
<div v-if="selectedVault" class="current-vault">
|
||||
<FileIcon v-if="selectedVault.file" :size="28" /><Link v-else :size="28" /><strong>{{
|
||||
selectedVault.note || selectedVault.file?.name || selectedVault.url
|
||||
}}</strong
|
||||
><small v-if="selectedVault.note">{{
|
||||
selectedVault.file?.name || selectedVault.url
|
||||
}}</small
|
||||
><button
|
||||
v-if="!transfer"
|
||||
class="text-button"
|
||||
:disabled="!mutable"
|
||||
@click="selectedVault = null"
|
||||
>
|
||||
다른 자료 선택
|
||||
</button>
|
||||
</div>
|
||||
<template v-else
|
||||
><button
|
||||
v-for="item in vaultItems"
|
||||
:key="item.accessId"
|
||||
class="vault-choice"
|
||||
:class="{ chosen: selectedVault?.accessId === item.accessId }"
|
||||
:aria-pressed="selectedVault?.accessId === item.accessId"
|
||||
:disabled="!mutable"
|
||||
@click="selectVault(item)"
|
||||
>
|
||||
<FileIcon v-if="item.file" :size="18" /><Link v-else :size="18" /><span
|
||||
><strong>{{ item.note || item.file?.name || item.url }}</strong
|
||||
><small
|
||||
>{{ item.role === 'self' ? '임시 저장' : '받은 항목' }} ·
|
||||
{{ item.file ? '파일' : '링크' }}</small
|
||||
></span
|
||||
><Check v-if="selectedVault?.accessId === item.accessId" :size="16" />
|
||||
</button>
|
||||
<p v-if="!vaultItems?.length" class="content-feedback">
|
||||
{{ busy ? '보관함을 불러오고 있어요.' : '보관함이 비어 있어요.' }}
|
||||
</p>
|
||||
<button
|
||||
v-if="vaultCursor"
|
||||
class="text-button"
|
||||
:disabled="busy"
|
||||
@click="emit('vault-more')"
|
||||
>
|
||||
더 보기
|
||||
</button></template
|
||||
>
|
||||
</template>
|
||||
<p v-if="transfer" class="composer-transfer-state" role="status">
|
||||
{{ transfer.status === 'pending' ? '상대 기기의 수락을 기다립니다.' : '전송 중입니다.' }}
|
||||
</p>
|
||||
<div v-for="(upload, index) in progress" :key="index" class="composer-progress">
|
||||
<span
|
||||
>{{ upload.name }} ·
|
||||
{{ upload.status === 'failed' ? upload.message : `${upload.progress}%` }}</span
|
||||
><progress :value="upload.progress" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="button primary content-submit"
|
||||
:class="{ 'link-send': source === 'url', 'send-button': source === 'file' }"
|
||||
:disabled="busy || disabled || (!transfer && !ready)"
|
||||
@click="submit"
|
||||
>
|
||||
{{ transfer ? '전송 취소' : saveMode ? '저장하기' : '전송하기'
|
||||
}}<X v-if="transfer" :size="16" /><ArrowUpRight v-else :size="16" />
|
||||
</button>
|
||||
<div v-if="!contentMode" class="content-switches">
|
||||
<button v-if="source !== 'file'" class="text-button" :disabled="!mutable" @click="pickFiles">
|
||||
<FileIcon :size="14" />대신 파일 선택하기</button
|
||||
><button v-if="source !== 'url'" class="text-button" :disabled="!mutable" @click="toLink">
|
||||
<Link :size="14" />대신 링크 붙여넣기</button
|
||||
><button
|
||||
v-if="vaultEnabled && source !== 'vault'"
|
||||
class="text-button"
|
||||
:disabled="!mutable"
|
||||
@click="toVault"
|
||||
>
|
||||
<Inbox :size="14" />보관함에서 가져오기
|
||||
</button>
|
||||
</div>
|
||||
<p class="consent-note">
|
||||
{{ saveMode ? '저장한 자료는 최대 7일 후 삭제됩니다.' : '상대가 수락한 후에만 전달됩니다.' }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
409
apps/web/src/client.js
Normal file
409
apps/web/src/client.js
Normal file
@@ -0,0 +1,409 @@
|
||||
import { createRefreshQueue } from './refreshQueue';
|
||||
import { ref } from 'vue';
|
||||
import { io } from 'socket.io-client';
|
||||
export const account = ref(null),
|
||||
registrationOpen = ref(false),
|
||||
passwordMinLength = ref(12);
|
||||
export async function refreshAccount() {
|
||||
const result = await api('/auth/me');
|
||||
account.value = result.account;
|
||||
registrationOpen.value = result.registrationOpen;
|
||||
passwordMinLength.value = result.passwordMinLength ?? 12;
|
||||
}
|
||||
export function reconnect() {
|
||||
connect();
|
||||
}
|
||||
export const device = ref(null),
|
||||
csrfToken = ref(''),
|
||||
sessionExpiresAt = ref(0),
|
||||
connected = ref(false),
|
||||
peers = ref([]),
|
||||
requests = ref([]),
|
||||
items = ref([]),
|
||||
spaces = ref([]),
|
||||
discoveryEnabled = ref(false),
|
||||
error = ref('');
|
||||
export const nextCursor = ref(null);
|
||||
let socket;
|
||||
let reauthorizing;
|
||||
const drafts = new Map();
|
||||
const completing = new Set();
|
||||
export async function api(path, method = 'GET', body, key) {
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(method !== 'GET' ? { 'X-CSRF-Token': csrfToken.value } : {}),
|
||||
...(key ? { 'Idempotency-Key': key } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.message ?? '요청을 처리하지 못했습니다.');
|
||||
return payload;
|
||||
}
|
||||
export async function bootstrap() {
|
||||
const init = async () => {
|
||||
const s = await api('/guest/session', 'POST', {});
|
||||
device.value = s.device;
|
||||
csrfToken.value = s.csrfToken;
|
||||
sessionExpiresAt.value = s.expiresAt;
|
||||
};
|
||||
if (navigator.locks) await navigator.locks.request('hop-session-bootstrap', init);
|
||||
else await init();
|
||||
await refreshAccount();
|
||||
connect();
|
||||
await refresh();
|
||||
startReconciliation();
|
||||
}
|
||||
function connect() {
|
||||
socket?.disconnect();
|
||||
socket = io({ auth: { csrfToken: csrfToken.value }, reconnection: true });
|
||||
socket.on('connect', () => {
|
||||
connected.value = true;
|
||||
void refresh().catch(showError);
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
connected.value = false;
|
||||
});
|
||||
socket.on('inbox:updated', () => window.dispatchEvent(new Event('hop-account-updated')));
|
||||
socket.on('account:revoked', () => {
|
||||
clearAccountUploads(account.value?.id);
|
||||
account.value = null;
|
||||
window.dispatchEvent(new Event('hop-account-revoked'));
|
||||
});
|
||||
socket.on('connect_error', (err) => {
|
||||
connected.value = false;
|
||||
if (err.message !== 'SESSION_EXPIRED' || reauthorizing) return;
|
||||
reauthorizing = (async () => {
|
||||
const session = await api('/guest/session', 'POST', {});
|
||||
device.value = session.device;
|
||||
csrfToken.value = session.csrfToken;
|
||||
sessionExpiresAt.value = session.expiresAt;
|
||||
if (socket) {
|
||||
socket.auth = { csrfToken: csrfToken.value };
|
||||
socket.connect();
|
||||
}
|
||||
})()
|
||||
.catch(showError)
|
||||
.finally(() => {
|
||||
reauthorizing = undefined;
|
||||
});
|
||||
});
|
||||
for (const event of [
|
||||
'device:online',
|
||||
'device:offline',
|
||||
'device:updated',
|
||||
'space:updated',
|
||||
'request:created',
|
||||
'request:updated',
|
||||
'transfer:updated',
|
||||
'access:updated',
|
||||
])
|
||||
socket.on(event, () => void refresh().catch(showError));
|
||||
}
|
||||
export function showError(err) {
|
||||
error.value = err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.';
|
||||
}
|
||||
export const refresh = createRefreshQueue(async () => {
|
||||
const snapshot = await api('/device/snapshot');
|
||||
device.value = snapshot.device;
|
||||
peers.value = snapshot.devices;
|
||||
discoveryEnabled.value = snapshot.sameNetworkDiscoveryEnabled;
|
||||
requests.value = snapshot.requests;
|
||||
items.value = snapshot.items;
|
||||
nextCursor.value = snapshot.nextCursor;
|
||||
spaces.value = snapshot.spaces;
|
||||
for (const req of requests.value) {
|
||||
if (
|
||||
req.kind === 'file' &&
|
||||
req.status === 'accepted' &&
|
||||
req.transferStatus === 'waiting' &&
|
||||
fileDrafts.has(req.id) &&
|
||||
!Object.keys(uploadProgress.value).some(
|
||||
(k) => k.startsWith(req.id) && uploadProgress.value[k]?.status === 'failed',
|
||||
)
|
||||
)
|
||||
void deliverFiles(req.id);
|
||||
if (req.status === 'accepted' && req.transferStatus === 'waiting' && drafts.has(req.id))
|
||||
void deliver(req.id);
|
||||
if (
|
||||
['rejected', 'expired', 'cancelled'].includes(req.status) ||
|
||||
req.transferStatus === 'stored'
|
||||
)
|
||||
drafts.delete(req.id);
|
||||
}
|
||||
});
|
||||
export async function send(targetDeviceId, kind, values) {
|
||||
const content = values.urls.join('\n');
|
||||
const { requestId } = await api(
|
||||
'/transfer-requests',
|
||||
'POST',
|
||||
{
|
||||
targetDeviceId,
|
||||
kind,
|
||||
byteSize: new TextEncoder().encode(content).length,
|
||||
count: values.urls.length,
|
||||
},
|
||||
crypto.randomUUID(),
|
||||
);
|
||||
drafts.set(requestId, { ...values, key: crypto.randomUUID() });
|
||||
await refresh();
|
||||
return requestId;
|
||||
}
|
||||
async function deliver(id) {
|
||||
if (completing.has(id)) return;
|
||||
const draft = drafts.get(id);
|
||||
if (!draft) return;
|
||||
completing.add(id);
|
||||
try {
|
||||
await api(`/transfers/${id}/complete`, 'POST', { urls: draft.urls }, draft.key);
|
||||
drafts.delete(id);
|
||||
await refresh();
|
||||
return true;
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
return false;
|
||||
} finally {
|
||||
completing.delete(id);
|
||||
}
|
||||
}
|
||||
export async function retryDelivery(id, values) {
|
||||
drafts.set(id, { ...values, key: crypto.randomUUID() });
|
||||
if (!(await deliver(id))) throw new Error(error.value || '전송을 완료하지 못했습니다.');
|
||||
}
|
||||
export async function requestAction(id, action) {
|
||||
await api(
|
||||
`/transfer-requests/${id}/${action}`,
|
||||
'POST',
|
||||
{},
|
||||
action === 'accept' ? crypto.randomUUID() : undefined,
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
export async function patchDevice(input) {
|
||||
await api('/device/me', 'PATCH', input);
|
||||
await refresh();
|
||||
}
|
||||
export async function markRead(item, value = true) {
|
||||
await api(`/access/${item.accessId}/read`, 'PATCH', { read: value });
|
||||
await refresh();
|
||||
}
|
||||
export async function loadMore() {
|
||||
if (!nextCursor.value) return;
|
||||
const result = await api(`/device/items?cursor=${encodeURIComponent(nextCursor.value)}`);
|
||||
items.value.push(...result.items);
|
||||
nextCursor.value = result.nextCursor;
|
||||
}
|
||||
export async function reset() {
|
||||
const result = await api('/device/reset', 'POST', {});
|
||||
drafts.clear();
|
||||
fileDrafts.clear();
|
||||
for (const [key, xhr] of uploadRequests) if (!uploadProgress.value[key]?.accountId) xhr.abort();
|
||||
uploadProgress.value = Object.fromEntries(
|
||||
Object.entries(uploadProgress.value).filter(([, p]) => p.accountId),
|
||||
);
|
||||
device.value = result.device;
|
||||
csrfToken.value = result.csrfToken;
|
||||
sessionExpiresAt.value = result.expiresAt;
|
||||
connect();
|
||||
await refresh();
|
||||
}
|
||||
export const uploadProgress = ref({});
|
||||
const fileDrafts = new Map();
|
||||
const fileSending = new Set();
|
||||
const uploadRequests = new Map();
|
||||
const cancelledTransfers = new Set();
|
||||
export async function sendFiles(targetDeviceId, files) {
|
||||
const input = {
|
||||
targetDeviceId,
|
||||
kind: 'file',
|
||||
byteSize: files.reduce((n, f) => n + f.size, 0),
|
||||
count: files.length,
|
||||
files: files.map((f) => ({
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type || 'application/octet-stream',
|
||||
})),
|
||||
};
|
||||
const { requestId } = await api('/transfer-requests', 'POST', input, crypto.randomUUID());
|
||||
fileDrafts.set(requestId, files);
|
||||
await refresh();
|
||||
return requestId;
|
||||
}
|
||||
export function uploadFile(id, index, file, accountId) {
|
||||
const progressKey = `${id}:${index}`;
|
||||
uploadProgress.value[progressKey] = {
|
||||
name: file.name,
|
||||
progress: 0,
|
||||
status: 'uploading',
|
||||
accountId,
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
uploadRequests.set(progressKey, xhr);
|
||||
xhr.open('POST', `/api/uploads?requestId=${id}&fileIndex=${index}`);
|
||||
xhr.setRequestHeader('X-CSRF-Token', csrfToken.value);
|
||||
xhr.setRequestHeader('Idempotency-Key', crypto.randomUUID());
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable)
|
||||
uploadProgress.value[progressKey].progress = Math.min(
|
||||
100,
|
||||
Math.round((e.loaded / e.total) * 100),
|
||||
);
|
||||
};
|
||||
const finish = () => uploadRequests.delete(progressKey);
|
||||
xhr.onload = () => {
|
||||
finish();
|
||||
if (accountId && account.value?.id !== accountId) {
|
||||
delete uploadProgress.value[progressKey];
|
||||
reject(Error('계정 세션이 종료되었습니다.'));
|
||||
return;
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
uploadProgress.value[progressKey] = { name: file.name, progress: 100, status: 'stored' };
|
||||
resolve();
|
||||
} else {
|
||||
let message = '업로드하지 못했습니다.';
|
||||
try {
|
||||
message = JSON.parse(xhr.responseText).message;
|
||||
} catch {}
|
||||
uploadProgress.value[progressKey] = {
|
||||
name: file.name,
|
||||
progress: 0,
|
||||
status: 'failed',
|
||||
accountId,
|
||||
message,
|
||||
};
|
||||
reject(Error(message));
|
||||
}
|
||||
};
|
||||
xhr.onerror = xhr.onabort = () => {
|
||||
finish();
|
||||
if (accountId && account.value?.id !== accountId) {
|
||||
delete uploadProgress.value[progressKey];
|
||||
reject(Error('계정 세션이 종료되었습니다.'));
|
||||
return;
|
||||
}
|
||||
uploadProgress.value[progressKey] = {
|
||||
name: file.name,
|
||||
progress: 0,
|
||||
status: 'failed',
|
||||
accountId,
|
||||
message: '업로드가 중단되었습니다.',
|
||||
};
|
||||
reject(Error('업로드가 중단되었습니다.'));
|
||||
};
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
async function deliverFiles(id) {
|
||||
if (fileSending.has(id)) return;
|
||||
const files = fileDrafts.get(id),
|
||||
request = requests.value.find((r) => r.id === id);
|
||||
if (!files || !request) return;
|
||||
fileSending.add(id);
|
||||
try {
|
||||
for (const [index, file] of files.entries()) {
|
||||
if (cancelledTransfers.has(id)) break;
|
||||
if (request.files?.[index]?.status === 'stored') continue;
|
||||
try {
|
||||
await uploadFile(id, index, file);
|
||||
} catch (err) {
|
||||
if (!cancelledTransfers.has(id)) showError(err);
|
||||
}
|
||||
if (requests.value.find((r) => r.id === id)?.status !== 'accepted') break;
|
||||
}
|
||||
await refresh();
|
||||
if (requests.value.find((r) => r.id === id)?.transferStatus === 'stored') fileDrafts.delete(id);
|
||||
} finally {
|
||||
fileSending.delete(id);
|
||||
}
|
||||
}
|
||||
export async function retryFiles(id, files) {
|
||||
const r = requests.value.find((r) => r.id === id);
|
||||
if (
|
||||
!r?.files ||
|
||||
r.files.length !== files.length ||
|
||||
files.some((f, i) => f.name !== r.files[i].name || f.size !== r.files[i].size)
|
||||
)
|
||||
throw Error('수락한 파일 목록과 이름·크기가 같은 파일을 선택해 주세요.');
|
||||
fileDrafts.set(id, files);
|
||||
await deliverFiles(id);
|
||||
}
|
||||
export function cancelUpload(key) {
|
||||
uploadRequests.get(key)?.abort();
|
||||
}
|
||||
export async function cancelTransfer(id) {
|
||||
// Cancel on the server before aborting uploads so a failed upload cannot restart delivery.
|
||||
cancelledTransfers.add(id);
|
||||
try {
|
||||
await api(`/transfer-requests/${id}/cancel`, 'POST', {});
|
||||
} catch (error) {
|
||||
cancelledTransfers.delete(id);
|
||||
throw error;
|
||||
}
|
||||
drafts.delete(id);
|
||||
fileDrafts.delete(id);
|
||||
for (const key of uploadRequests.keys()) {
|
||||
if (key.startsWith(`${id}:`)) uploadRequests.get(key)?.abort();
|
||||
}
|
||||
for (const key of Object.keys(uploadProgress.value)) {
|
||||
if (key.startsWith(`${id}:`)) delete uploadProgress.value[key];
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
export async function deleteItems(ids) {
|
||||
const original = [...items.value];
|
||||
items.value = items.value.filter((i) => !ids.includes(i.accessId));
|
||||
try {
|
||||
const result = await api('/access/delete-batch', 'POST', { ids });
|
||||
await refresh();
|
||||
return result;
|
||||
} catch (err) {
|
||||
items.value = original;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
export async function undoDelete(id) {
|
||||
await api(`/deletion-operations/${id}/undo`, 'POST', {});
|
||||
await refresh();
|
||||
}
|
||||
export async function download(item) {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/access/${item.accessId}/download`;
|
||||
a.download = item.file?.name ?? '';
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
await markRead(item);
|
||||
}
|
||||
export function clearAccountUploads(accountId) {
|
||||
for (const [key, progress] of Object.entries(uploadProgress.value)) {
|
||||
if (!progress.accountId || (accountId && progress.accountId !== accountId)) continue;
|
||||
uploadRequests.get(key)?.abort();
|
||||
delete uploadProgress.value[key];
|
||||
}
|
||||
}
|
||||
let reconciliation;
|
||||
function startReconciliation() {
|
||||
if (reconciliation) return;
|
||||
reconciliation = setInterval(() => {
|
||||
if (
|
||||
document.visibilityState !== 'visible' ||
|
||||
!requests.value.some(
|
||||
(r) => r.status === 'pending' || (r.status === 'accepted' && r.transferStatus !== 'stored'),
|
||||
)
|
||||
)
|
||||
return;
|
||||
void api('/transfer-requests')
|
||||
.then((result) => {
|
||||
const summary = (rs) => JSON.stringify(rs.map((r) => [r.id, r.status, r.transferStatus]));
|
||||
if (summary(result.requests) !== summary(requests.value)) return refresh();
|
||||
})
|
||||
.catch(showError);
|
||||
}, 5000);
|
||||
}
|
||||
83
apps/web/src/deliveryReceiver.js
Normal file
83
apps/web/src/deliveryReceiver.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
// Reserve tabs during the acceptance click; later socket updates have no user activation.
|
||||
// Only the tab that accepts a request performs its delivery actions.
|
||||
export function createDeliveryReceiver({ download, markRead, onError }) {
|
||||
const pending = new Map();
|
||||
const blockedLinks = ref([]);
|
||||
function reserve(request) {
|
||||
if (pending.has(request.id)) return;
|
||||
const tabs = [];
|
||||
if (request.kind === 'url') {
|
||||
for (let i = 0; i < request.count; i++) {
|
||||
let tab = null;
|
||||
try {
|
||||
tab = window.open('about:blank', '_blank');
|
||||
} catch {
|
||||
/* Browser restriction. */
|
||||
}
|
||||
if (tab) {
|
||||
tab.opener = null;
|
||||
tab.document.title = 'hop. 링크를 받는 중';
|
||||
tab.document.body.textContent = '수락한 링크를 받고 있어요. 잠시만 기다려 주세요.';
|
||||
}
|
||||
tabs.push(tab);
|
||||
}
|
||||
}
|
||||
pending.set(request.id, { tabs, handled: new Set() });
|
||||
}
|
||||
function abort(id) {
|
||||
const delivery = pending.get(id);
|
||||
for (const tab of delivery?.tabs ?? []) tab?.close();
|
||||
pending.delete(id);
|
||||
}
|
||||
function reconcile(requests, items) {
|
||||
for (const [id, delivery] of pending) {
|
||||
const request = requests.find((r) => r.id === id);
|
||||
if (!request) continue;
|
||||
if (
|
||||
['rejected', 'cancelled', 'expired', 'blocked'].includes(request.status) ||
|
||||
(request.status === 'pending' && request.expiresAt <= Date.now())
|
||||
) {
|
||||
abort(id);
|
||||
continue;
|
||||
}
|
||||
for (const item of items.filter((i) => i.batchId === request.batchId)) {
|
||||
if (delivery.handled.has(item.accessId)) continue;
|
||||
delivery.handled.add(item.accessId);
|
||||
if (item.file) {
|
||||
void download(item).catch(onError);
|
||||
} else if (item.url) {
|
||||
const tab = delivery.tabs.shift();
|
||||
if (tab && !tab.closed) {
|
||||
try {
|
||||
tab.location.replace(item.url);
|
||||
void markRead(item).catch(onError);
|
||||
} catch {
|
||||
tab.close();
|
||||
blockedLinks.value.push(item);
|
||||
}
|
||||
} else blockedLinks.value.push(item);
|
||||
}
|
||||
}
|
||||
if (request.transferStatus === 'stored' && delivery.handled.size >= request.count) abort(id);
|
||||
}
|
||||
}
|
||||
function opened(item) {
|
||||
blockedLinks.value = blockedLinks.value.filter((i) => i.accessId !== item.accessId);
|
||||
void markRead(item).catch(onError);
|
||||
}
|
||||
function dispose() {
|
||||
for (const id of pending.keys()) abort(id);
|
||||
blockedLinks.value = [];
|
||||
}
|
||||
return {
|
||||
reserve,
|
||||
abort,
|
||||
reconcile,
|
||||
blockedLinks,
|
||||
opened,
|
||||
dispose,
|
||||
hasPending: () => pending.size > 0,
|
||||
};
|
||||
}
|
||||
6
apps/web/src/main.js
Normal file
6
apps/web/src/main.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { initialTheme, applyTheme } from './theme';
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
applyTheme(initialTheme());
|
||||
createApp(App).mount('#app');
|
||||
19
apps/web/src/refreshQueue.js
Normal file
19
apps/web/src/refreshQueue.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/** Keep invalidations arriving during an in-flight snapshot. Returning the existing promise
|
||||
* alone discards later changes and can strand an accepted transfer in its pending snapshot. */
|
||||
export function createRefreshQueue(load) {
|
||||
let requested = false;
|
||||
let running;
|
||||
return function refresh() {
|
||||
requested = true;
|
||||
if (running) return running;
|
||||
running = (async () => {
|
||||
do {
|
||||
requested = false;
|
||||
await load();
|
||||
} while (requested);
|
||||
})().finally(() => {
|
||||
running = undefined;
|
||||
});
|
||||
return running;
|
||||
};
|
||||
}
|
||||
3116
apps/web/src/style.css
Normal file
3116
apps/web/src/style.css
Normal file
File diff suppressed because it is too large
Load Diff
23
apps/web/src/theme.js
Normal file
23
apps/web/src/theme.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export function initialTheme() {
|
||||
const saved = localStorage.getItem('hop-theme');
|
||||
const current = localStorage.getItem('hop-theme-version') === '2';
|
||||
const theme =
|
||||
saved === 'light' || saved === 'dark'
|
||||
? saved
|
||||
: saved === 'system' && current
|
||||
? 'system'
|
||||
: 'dark';
|
||||
localStorage.setItem('hop-theme', theme);
|
||||
localStorage.setItem('hop-theme-version', '2');
|
||||
return theme;
|
||||
}
|
||||
export function applyTheme(theme) {
|
||||
const dark =
|
||||
theme === 'dark' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute('content', dark ? '#000000' : '#fafafa');
|
||||
localStorage.setItem('hop-theme', theme);
|
||||
localStorage.setItem('hop-theme-version', '2');
|
||||
}
|
||||
15
apps/web/vite.config.js
Normal file
15
apps/web/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
strictPort: false,
|
||||
proxy: {
|
||||
'/api': process.env.HOP_API_URL ?? 'http://127.0.0.1:0',
|
||||
'/socket.io': { target: process.env.HOP_API_URL ?? 'http://127.0.0.1:0', ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user