Initial hop. implementation and UGREEN NAS deployment guide
This commit is contained in:
175
packages/shared/src/index.ts
Normal file
175
packages/shared/src/index.ts
Normal 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,
|
||||
);
|
||||
Reference in New Issue
Block a user