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"]
|
||||
}
|
||||
Reference in New Issue
Block a user