Initial hop. implementation and UGREEN NAS deployment guide

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

View File

@@ -0,0 +1,175 @@
import { z } from 'zod';
export const devicePatch = z
.object({
name: z
.string()
.trim()
.min(1)
.max(40)
.refine((v) => !/[\x00-\x1f\x7f<>]/.test(v), '제어문자와 HTML 문자는 사용할 수 없습니다.')
.optional(),
discoverable: z.boolean().optional(),
})
.strict();
export const fileMetadata = z
.object({
name: z
.string()
.min(1)
.max(255)
.refine((v) => !/[\x00-\x1f\x7f]/.test(v)),
size: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
type: z.string().max(255).default('application/octet-stream'),
})
.strict();
export const requestInput = z
.object({
targetDeviceId: z.uuid(),
kind: z.enum(['url', 'file']),
byteSize: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
count: z.number().int().min(1).max(1000),
files: z.array(fileMetadata).max(1000).optional(),
})
.strict()
.refine((v) =>
v.kind === 'file'
? Boolean(
v.files?.length === v.count && v.files.reduce((sum, f) => sum + f.size, 0) === v.byteSize,
)
: !v.files,
);
export const completeInput = z
.object({
urls: z.array(z.string().max(8192)).min(1).max(20),
})
.strict();
export const spaceInput = z.object({ approvalRequired: z.boolean().default(false) }).strict();
export const joinInput = z
.object({
code: z
.string()
.trim()
.toUpperCase()
.regex(/^[A-Z2-9]{8}$/)
.optional(),
token: z
.string()
.regex(/^[A-Za-z0-9_-]{43}$/)
.optional(),
})
.strict()
.refine((v) => Boolean(v.code) !== Boolean(v.token));
export type Device = { id: string; name: string; code: string; discoverable: boolean };
export type Peer = Device & { sources: ('network' | 'space')[] };
export type RequestStatus = 'pending' | 'accepted' | 'rejected' | 'expired' | 'cancelled';
export type TransferRequest = {
id: string;
batchId: string;
senderDevice: Device | null;
recipientDevice: Device | null;
kind: 'url' | 'file';
byteSize: number;
count: number;
status: RequestStatus;
transferStatus: 'waiting' | 'uploading' | 'stored' | 'failed';
files?: {
id: string;
name: string;
size: number;
type: string;
status: 'pending' | 'uploading' | 'stored' | 'failed' | 'cleaning';
}[];
createdAt: number;
expiresAt: number;
startBy: number | null;
direction: 'sent' | 'received';
};
export type Item = {
accessId: string;
id: string;
batchId: string;
kind: 'url' | 'file';
url: string | null;
file?: { id: string; name: string; size: number; type: string };
role: 'sender' | 'recipient' | 'self';
read: boolean;
peerName: string;
peerCode: string;
createdAt: number;
expiresAt: number;
recipientRead: boolean;
};
export type Space = {
id: string;
creatorDeviceId: string;
approvalRequired: boolean;
expiresAt: number;
members: { device: Device; status: 'pending' | 'approved' }[];
};
export type SessionResponse = { device: Device; csrfToken: string; expiresAt: number };
export function parseUrls(value: string): { urls: string[]; invalid: string[] } {
const entries = value
.split(/\r?\n/)
.map((v) => v.trim())
.filter(Boolean);
const urls: string[] = [];
const invalid: string[] = [];
for (const v of entries) {
try {
const u = new URL(v);
if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password) throw Error();
urls.push(u.href);
} catch {
invalid.push(v);
}
}
return { urls, invalid };
}
export type Account = {
id: string;
email: string;
displayName: string;
role: 'user' | 'admin';
status: 'pending' | 'active' | 'suspended' | 'deleted';
verified: boolean;
};
export type AccountItem = Item & {
note?: string | null;
favorite: boolean;
senderEmail?: string;
senderName?: string;
};
export type EmailRequest = {
id: string;
batchId: string;
kind: 'url' | 'file';
byteSize: number;
count: number;
status: RequestStatus;
transferStatus: 'waiting' | 'uploading' | 'stored' | 'failed';
createdAt: number;
expiresAt: number;
startBy: number | null;
direction: 'sent' | 'received';
sender: Account;
recipient: Account;
files?: TransferRequest['files'];
};
export const emailRequestInput = z
.object({
mode: z.literal('email'),
email: z.string().max(254),
kind: requestInput.shape.kind,
byteSize: requestInput.shape.byteSize,
count: requestInput.shape.count,
files: requestInput.shape.files,
})
.strict()
.refine((v) =>
v.kind === 'file'
? Boolean(
v.files?.length === v.count && v.files.reduce((sum, f) => sum + f.size, 0) === v.byteSize,
)
: !v.files,
);