实现快传,完善快传和网盘的功能,实现文件的互传等一系列功能

This commit is contained in:
yoyuzh
2026-03-20 14:16:18 +08:00
parent 944ab6dbf8
commit 43358e29d7
109 changed files with 5237 additions and 2465 deletions

View File

@@ -60,7 +60,7 @@ test('scoped cache key includes current user identity', () => {
},
});
assert.equal(buildScopedCacheKey('school', '2023123456', '2025-spring'), 'portal-cache:user:7:school:2023123456:2025-spring');
assert.equal(buildScopedCacheKey('transfer', 'pickup-code', '849201'), 'portal-cache:user:7:transfer:pickup-code:849201');
});
test('cached values are isolated between users', () => {
@@ -73,9 +73,9 @@ test('cached values are isolated between users', () => {
createdAt: '2026-03-14T12:00:00',
},
});
writeCachedValue(buildScopedCacheKey('school', '2023123456', '2025-spring'), {
writeCachedValue(buildScopedCacheKey('transfer', 'pickup-code', '849201'), {
queried: true,
grades: [95],
sharedFiles: [2],
});
saveStoredSession({
@@ -88,12 +88,12 @@ test('cached values are isolated between users', () => {
},
});
assert.equal(readCachedValue(buildScopedCacheKey('school', '2023123456', '2025-spring')), null);
assert.equal(readCachedValue(buildScopedCacheKey('transfer', 'pickup-code', '849201')), null);
});
test('invalid cached json is ignored safely', () => {
localStorage.setItem('portal-cache:user:7:school:2023123456:2025-spring', '{broken-json');
localStorage.setItem('portal-cache:user:7:transfer:pickup-code:849201', '{broken-json');
assert.equal(readCachedValue('portal-cache:user:7:school:2023123456:2025-spring'), null);
assert.equal(localStorage.getItem('portal-cache:user:7:school:2023123456:2025-spring'), null);
assert.equal(readCachedValue('portal-cache:user:7:transfer:pickup-code:849201'), null);
assert.equal(localStorage.getItem('portal-cache:user:7:transfer:pickup-code:849201'), null);
});

View File

@@ -0,0 +1,12 @@
import { apiRequest } from './api';
import { normalizeNetdiskTargetPath } from './netdisk-upload';
import type { FileMetadata } from './types';
export function copyFileToNetdiskPath(fileId: number, path: string) {
return apiRequest<FileMetadata>(`/files/${fileId}/copy`, {
method: 'POST',
body: {
path: normalizeNetdiskTargetPath(path, '/'),
},
});
}

View File

@@ -0,0 +1,12 @@
import { apiRequest } from './api';
import { normalizeNetdiskTargetPath } from './netdisk-upload';
import type { FileMetadata } from './types';
export function moveFileToNetdiskPath(fileId: number, path: string) {
return apiRequest<FileMetadata>(`/files/${fileId}/move`, {
method: 'PATCH',
body: {
path: normalizeNetdiskTargetPath(path, '/'),
},
});
}

View File

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildFileShareUrl,
FILE_SHARE_ROUTE_PREFIX,
getPostLoginRedirectPath,
} from './file-share';
test('buildFileShareUrl builds a browser-router share url', () => {
assert.equal(
buildFileShareUrl('https://yoyuzh.xyz', 'share-token-1', 'browser'),
'https://yoyuzh.xyz/share/share-token-1',
);
});
test('buildFileShareUrl builds a hash-router share url', () => {
assert.equal(
buildFileShareUrl('https://yoyuzh.xyz/', 'share-token-1', 'hash'),
'https://yoyuzh.xyz/#/share/share-token-1',
);
});
test('getPostLoginRedirectPath keeps safe in-site paths only', () => {
assert.equal(getPostLoginRedirectPath('/share/share-token-1'), '/share/share-token-1');
assert.equal(getPostLoginRedirectPath('https://evil.example.com'), '/overview');
assert.equal(getPostLoginRedirectPath(null), '/overview');
});
test('FILE_SHARE_ROUTE_PREFIX stays aligned with the public share route', () => {
assert.equal(FILE_SHARE_ROUTE_PREFIX, '/share');
});

View File

@@ -0,0 +1,49 @@
import { apiRequest } from './api';
import { getTransferRouterMode, type TransferRouterMode } from './transfer-links';
import type { CreateFileShareLinkResponse, FileMetadata, FileShareDetailsResponse } from './types';
export const FILE_SHARE_ROUTE_PREFIX = '/share';
export function buildFileShareUrl(
origin: string,
token: string,
routerMode: TransferRouterMode = 'browser',
) {
const normalizedOrigin = origin.replace(/\/+$/, '');
const encodedToken = encodeURIComponent(token);
if (routerMode === 'hash') {
return `${normalizedOrigin}/#${FILE_SHARE_ROUTE_PREFIX}/${encodedToken}`;
}
return `${normalizedOrigin}${FILE_SHARE_ROUTE_PREFIX}/${encodedToken}`;
}
export function getPostLoginRedirectPath(nextPath: string | null, fallback = '/overview') {
if (!nextPath || !nextPath.startsWith('/') || nextPath.startsWith('//')) {
return fallback;
}
return nextPath;
}
export function createFileShareLink(fileId: number) {
return apiRequest<CreateFileShareLinkResponse>(`/files/${fileId}/share-links`, {
method: 'POST',
});
}
export function getFileShareDetails(token: string) {
return apiRequest<FileShareDetailsResponse>(`/files/share-links/${encodeURIComponent(token)}`);
}
export function importSharedFile(token: string, path: string) {
return apiRequest<FileMetadata>(`/files/share-links/${encodeURIComponent(token)}/import`, {
method: 'POST',
body: { path },
});
}
export function getCurrentFileShareUrl(token: string) {
return buildFileShareUrl(window.location.origin, token, getTransferRouterMode());
}

View File

@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getParentNetdiskPath,
joinNetdiskPath,
resolveTransferSaveDirectory,
splitNetdiskPath,
} from './netdisk-paths';
test('splitNetdiskPath normalizes root and nested paths', () => {
assert.deepEqual(splitNetdiskPath('/'), []);
assert.deepEqual(splitNetdiskPath('/下载/旅行/照片'), ['下载', '旅行', '照片']);
assert.deepEqual(splitNetdiskPath('下载//旅行/照片/'), ['下载', '旅行', '照片']);
});
test('joinNetdiskPath rebuilds a normalized absolute path', () => {
assert.equal(joinNetdiskPath([]), '/');
assert.equal(joinNetdiskPath(['下载', '旅行']), '/下载/旅行');
});
test('getParentNetdiskPath returns the previous directory level', () => {
assert.equal(getParentNetdiskPath('/下载/旅行'), '/下载');
assert.equal(getParentNetdiskPath('/下载'), '/');
});
test('resolveTransferSaveDirectory keeps nested transfer folders under the selected root path', () => {
assert.equal(resolveTransferSaveDirectory('相册/旅行/cover.jpg', '/下载'), '/下载/相册/旅行');
assert.equal(resolveTransferSaveDirectory('cover.jpg', '/下载'), '/下载');
});

View File

@@ -0,0 +1,31 @@
export function splitNetdiskPath(path: string | null | undefined) {
const rawPath = path?.trim();
if (!rawPath || rawPath === '/') {
return [] as string[];
}
return rawPath
.replaceAll('\\', '/')
.split('/')
.map((segment) => segment.trim())
.filter((segment) => segment && segment !== '.' && segment !== '..');
}
export function joinNetdiskPath(segments: string[]) {
return segments.length === 0 ? '/' : `/${segments.join('/')}`;
}
export function getParentNetdiskPath(path: string | null | undefined) {
const segments = splitNetdiskPath(path);
return joinNetdiskPath(segments.slice(0, -1));
}
export function resolveTransferSaveDirectory(relativePath: string | null | undefined, rootPath = '/下载') {
const rootSegments = splitNetdiskPath(rootPath);
const relativeSegments = splitNetdiskPath(relativePath);
if (relativeSegments.length <= 1) {
return joinNetdiskPath(rootSegments);
}
return joinNetdiskPath([...rootSegments, ...relativeSegments.slice(0, -1)]);
}

View File

@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeNetdiskTargetPath, resolveNetdiskSaveDirectory } from './netdisk-upload';
test('normalizeNetdiskTargetPath falls back to 下载 for blank paths', () => {
assert.equal(normalizeNetdiskTargetPath(undefined), '/下载');
assert.equal(normalizeNetdiskTargetPath(''), '/下载');
assert.equal(normalizeNetdiskTargetPath(' '), '/下载');
});
test('normalizeNetdiskTargetPath normalizes slash and root input', () => {
assert.equal(normalizeNetdiskTargetPath('/'), '/');
assert.equal(normalizeNetdiskTargetPath('下载/快传'), '/下载/快传');
assert.equal(normalizeNetdiskTargetPath('/下载/快传/'), '/下载/快传');
});
test('resolveNetdiskSaveDirectory keeps nested transfer folders under 下载', () => {
assert.equal(resolveNetdiskSaveDirectory('相册/旅行/cover.jpg'), '/下载/相册/旅行');
assert.equal(resolveNetdiskSaveDirectory('cover.jpg'), '/下载');
});
test('resolveNetdiskSaveDirectory ignores unsafe path segments', () => {
assert.equal(resolveNetdiskSaveDirectory('../相册//旅行/cover.jpg'), '/下载/相册/旅行');
});

View File

@@ -0,0 +1,60 @@
import { apiBinaryUploadRequest, apiRequest, apiUploadRequest, ApiError } from './api';
import { joinNetdiskPath, resolveTransferSaveDirectory, splitNetdiskPath } from './netdisk-paths';
import type { FileMetadata, InitiateUploadResponse } from './types';
export function normalizeNetdiskTargetPath(path: string | null | undefined, fallback = '/下载') {
const rawPath = path?.trim();
if (!rawPath) {
return fallback;
}
return joinNetdiskPath(splitNetdiskPath(rawPath === '/' ? '/' : rawPath)) || fallback;
}
export function resolveNetdiskSaveDirectory(relativePath: string | null | undefined, rootPath = '/下载') {
return normalizeNetdiskTargetPath(resolveTransferSaveDirectory(relativePath, rootPath));
}
export async function saveFileToNetdisk(file: File, path: string) {
const normalizedPath = normalizeNetdiskTargetPath(path);
const initiated = await apiRequest<InitiateUploadResponse>('/files/upload/initiate', {
method: 'POST',
body: {
path: normalizedPath,
filename: file.name,
contentType: file.type || null,
size: file.size,
},
});
if (initiated.direct) {
try {
await apiBinaryUploadRequest(initiated.uploadUrl, {
method: initiated.method,
headers: initiated.headers,
body: file,
});
return await apiRequest<FileMetadata>('/files/upload/complete', {
method: 'POST',
body: {
path: normalizedPath,
filename: file.name,
storageName: initiated.storageName,
contentType: file.type || null,
size: file.size,
},
});
} catch (error) {
if (!(error instanceof ApiError && error.isNetworkError)) {
throw error;
}
}
}
const formData = new FormData();
formData.append('file', file);
return apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(normalizedPath)}`, {
body: formData,
});
}

View File

@@ -1,41 +1,10 @@
import { buildScopedCacheKey, readCachedValue, writeCachedValue } from './cache';
import type { CourseResponse, FileMetadata, GradeResponse, UserProfile } from './types';
export interface SchoolQueryCache {
studentId: string;
semester: string;
}
export interface SchoolResultsCache {
queried: boolean;
schedule: CourseResponse[];
grades: GradeResponse[];
studentId: string;
semester: string;
}
import type { FileMetadata, UserProfile } from './types';
export interface OverviewCache {
profile: UserProfile | null;
recentFiles: FileMetadata[];
rootFiles: FileMetadata[];
schedule: CourseResponse[];
grades: GradeResponse[];
}
function getSchoolQueryCacheKey() {
return buildScopedCacheKey('school-query');
}
export function readStoredSchoolQuery() {
return readCachedValue<SchoolQueryCache>(getSchoolQueryCacheKey());
}
export function writeStoredSchoolQuery(query: SchoolQueryCache) {
writeCachedValue(getSchoolQueryCacheKey(), query);
}
export function getSchoolResultsCacheKey(studentId: string, semester: string) {
return buildScopedCacheKey('school-results', studentId, semester);
}
export function getOverviewCacheKey() {

View File

@@ -1,74 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { CourseResponse } from './types';
import { buildScheduleTable, getScheduleCellHeight, getScheduleDividerOffsets } from './schedule-table';
test('buildScheduleTable creates 12 sections with empty slots preserved', () => {
const schedule: CourseResponse[] = [
{
courseName: 'Advanced Java',
teacher: 'Li',
classroom: 'A101',
dayOfWeek: 1,
startTime: 1,
endTime: 2,
},
{
courseName: 'Networks',
teacher: 'Wang',
classroom: 'B202',
dayOfWeek: 3,
startTime: 5,
endTime: 6,
},
];
const table = buildScheduleTable(schedule);
assert.equal(table.length, 12);
assert.equal(table[0].slots.length, 7);
assert.equal(table[0].section, 1);
assert.equal(table[11].section, 12);
assert.equal(table[0].period, 'morning');
assert.equal(table[4].period, 'noon');
assert.equal(table[5].period, 'afternoon');
assert.equal(table[9].period, 'evening');
assert.equal(table[0].slots[0]?.course?.courseName, 'Advanced Java');
assert.equal(table[1].slots[0]?.type, 'covered');
assert.equal(table[2].slots[0]?.type, 'empty');
assert.equal(table[4].slots[2]?.course?.courseName, 'Networks');
assert.equal(table[5].slots[2]?.type, 'covered');
assert.equal(table[8].slots[4]?.type, 'empty');
assert.equal(table[8].slots[6]?.type, 'empty');
});
test('buildScheduleTable clamps invalid section ranges safely', () => {
const schedule: CourseResponse[] = [
{
courseName: 'Night Studio',
teacher: 'Xu',
classroom: 'C303',
dayOfWeek: 5,
startTime: 11,
endTime: 14,
},
];
const table = buildScheduleTable(schedule);
assert.equal(table[10].slots[4]?.rowSpan, 2);
assert.equal(table[11].slots[4]?.type, 'covered');
});
test('getScheduleCellHeight returns merged visual height for rowspan cells', () => {
assert.equal(getScheduleCellHeight(1), 96);
assert.equal(getScheduleCellHeight(2), 200);
assert.equal(getScheduleCellHeight(4), 408);
});
test('getScheduleDividerOffsets returns internal section boundaries for merged cells', () => {
assert.deepEqual(getScheduleDividerOffsets(1), []);
assert.deepEqual(getScheduleDividerOffsets(2), [100]);
assert.deepEqual(getScheduleDividerOffsets(4), [100, 204, 308]);
});

View File

@@ -1,77 +0,0 @@
import type { CourseResponse } from './types';
export interface ScheduleSlot {
type: 'empty' | 'course' | 'covered';
course?: CourseResponse;
rowSpan?: number;
}
export interface ScheduleRow {
section: number;
period: 'morning' | 'noon' | 'afternoon' | 'evening';
slots: ScheduleSlot[];
}
const SECTION_COUNT = 12;
const WEEKDAY_COUNT = 7;
const SECTION_CELL_HEIGHT = 96;
const SECTION_CELL_GAP = 8;
function getPeriod(section: number): ScheduleRow['period'] {
if (section <= 4) {
return 'morning';
}
if (section === 5) {
return 'noon';
}
if (section <= 8) {
return 'afternoon';
}
return 'evening';
}
export function buildScheduleTable(schedule: CourseResponse[]) {
const rows: ScheduleRow[] = Array.from({ length: SECTION_COUNT }, (_, index) => ({
section: index + 1,
period: getPeriod(index + 1),
slots: Array.from({ length: WEEKDAY_COUNT }, () => ({ type: 'empty' as const })),
}));
for (const course of schedule) {
const dayIndex = (course.dayOfWeek ?? 0) - 1;
if (dayIndex < 0 || dayIndex >= WEEKDAY_COUNT) {
continue;
}
const startSection = Math.max(1, Math.min(SECTION_COUNT, course.startTime ?? 1));
const endSection = Math.max(startSection, Math.min(SECTION_COUNT, course.endTime ?? startSection));
const rowSpan = endSection - startSection + 1;
const startRowIndex = startSection - 1;
rows[startRowIndex].slots[dayIndex] = {
type: 'course',
course,
rowSpan,
};
for (let section = startSection + 1; section <= endSection; section += 1) {
rows[section - 1].slots[dayIndex] = {
type: 'covered',
};
}
}
return rows;
}
export function getScheduleCellHeight(rowSpan: number) {
const safeRowSpan = Math.max(1, rowSpan);
return safeRowSpan * SECTION_CELL_HEIGHT + (safeRowSpan - 1) * SECTION_CELL_GAP;
}
export function getScheduleDividerOffsets(rowSpan: number) {
const safeRowSpan = Math.max(1, rowSpan);
return Array.from({ length: safeRowSpan - 1 }, (_, index) =>
(index + 1) * SECTION_CELL_HEIGHT + index * SECTION_CELL_GAP + SECTION_CELL_GAP / 2,
);
}

View File

@@ -1,22 +0,0 @@
import { apiRequest } from './api';
import { writeCachedValue } from './cache';
import { getSchoolResultsCacheKey, writeStoredSchoolQuery } from './page-cache';
import type { LatestSchoolDataResponse } from './types';
export async function fetchLatestSchoolData() {
return apiRequest<LatestSchoolDataResponse | null>('/cqu/latest');
}
export function cacheLatestSchoolData(latest: LatestSchoolDataResponse) {
writeStoredSchoolQuery({
studentId: latest.studentId,
semester: latest.semester,
});
writeCachedValue(getSchoolResultsCacheKey(latest.studentId, latest.semester), {
queried: true,
studentId: latest.studentId,
semester: latest.semester,
schedule: latest.schedule,
grades: latest.grades,
});
}

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildTransferArchiveFileName,
createTransferZipArchive,
} from './transfer-archive';
test('buildTransferArchiveFileName always returns a zip filename', () => {
assert.equal(buildTransferArchiveFileName('课堂资料'), '课堂资料.zip');
assert.equal(buildTransferArchiveFileName('课堂资料.zip'), '课堂资料.zip');
});
test('createTransferZipArchive creates a zip payload that keeps nested file paths', async () => {
const archive = await createTransferZipArchive([
{
name: 'report.pdf',
relativePath: '课程资料/report.pdf',
data: new TextEncoder().encode('report'),
},
{
name: 'notes.txt',
relativePath: '课程资料/notes.txt',
data: new TextEncoder().encode('notes'),
},
]);
const bytes = new Uint8Array(await archive.arrayBuffer());
const text = new TextDecoder().decode(bytes);
assert.equal(String.fromCharCode(...bytes.slice(0, 4)), 'PK\u0003\u0004');
assert.match(text, /课程资料\/report\.pdf/);
assert.match(text, /课程资料\/notes\.txt/);
});

View File

@@ -0,0 +1,171 @@
export interface TransferArchiveEntry {
name: string;
relativePath?: string;
data: Uint8Array | ArrayBuffer | Blob;
lastModified?: number;
}
const ZIP_UTF8_FLAG = 0x0800;
const CRC32_TABLE = createCrc32Table();
function createCrc32Table() {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) === 1 ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1);
}
table[index] = value >>> 0;
}
return table;
}
function sanitizeArchivePath(entry: TransferArchiveEntry) {
const rawPath = entry.relativePath?.trim() || entry.name;
const normalizedPath = rawPath
.replaceAll('\\', '/')
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
.join('/');
return normalizedPath || entry.name;
}
function crc32(bytes: Uint8Array) {
let value = 0xFFFFFFFF;
for (const byte of bytes) {
value = CRC32_TABLE[(value ^ byte) & 0xFF] ^ (value >>> 8);
}
return (value ^ 0xFFFFFFFF) >>> 0;
}
function toDosDateTime(timestamp: number) {
const date = new Date(timestamp);
const year = Math.max(1980, date.getFullYear());
const month = date.getMonth() + 1;
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = Math.floor(date.getSeconds() / 2);
return {
time: (hours << 11) | (minutes << 5) | seconds,
date: ((year - 1980) << 9) | (month << 5) | day,
};
}
function writeUint16(view: DataView, offset: number, value: number) {
view.setUint16(offset, value, true);
}
function writeUint32(view: DataView, offset: number, value: number) {
view.setUint32(offset, value >>> 0, true);
}
function concatUint8Arrays(chunks: Uint8Array[]) {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
const output = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
async function normalizeArchiveData(data: TransferArchiveEntry['data']) {
if (data instanceof Uint8Array) {
return data;
}
if (data instanceof Blob) {
return new Uint8Array(await data.arrayBuffer());
}
return new Uint8Array(data);
}
export function buildTransferArchiveFileName(baseName: string) {
return baseName.toLowerCase().endsWith('.zip') ? baseName : `${baseName}.zip`;
}
export async function createTransferZipArchive(entries: TransferArchiveEntry[]) {
const encoder = new TextEncoder();
const fileSections: Uint8Array[] = [];
const centralDirectorySections: Uint8Array[] = [];
let offset = 0;
for (const entry of entries) {
const fileName = sanitizeArchivePath(entry);
const fileNameBytes = encoder.encode(fileName);
const fileData = await normalizeArchiveData(entry.data);
const checksum = crc32(fileData);
const {time, date} = toDosDateTime(entry.lastModified ?? Date.now());
const localHeader = new Uint8Array(30);
const localHeaderView = new DataView(localHeader.buffer);
writeUint32(localHeaderView, 0, 0x04034B50);
writeUint16(localHeaderView, 4, 20);
writeUint16(localHeaderView, 6, ZIP_UTF8_FLAG);
writeUint16(localHeaderView, 8, 0);
writeUint16(localHeaderView, 10, time);
writeUint16(localHeaderView, 12, date);
writeUint32(localHeaderView, 14, checksum);
writeUint32(localHeaderView, 18, fileData.byteLength);
writeUint32(localHeaderView, 22, fileData.byteLength);
writeUint16(localHeaderView, 26, fileNameBytes.byteLength);
writeUint16(localHeaderView, 28, 0);
fileSections.push(localHeader, fileNameBytes, fileData);
const centralHeader = new Uint8Array(46);
const centralHeaderView = new DataView(centralHeader.buffer);
writeUint32(centralHeaderView, 0, 0x02014B50);
writeUint16(centralHeaderView, 4, 20);
writeUint16(centralHeaderView, 6, 20);
writeUint16(centralHeaderView, 8, ZIP_UTF8_FLAG);
writeUint16(centralHeaderView, 10, 0);
writeUint16(centralHeaderView, 12, time);
writeUint16(centralHeaderView, 14, date);
writeUint32(centralHeaderView, 16, checksum);
writeUint32(centralHeaderView, 20, fileData.byteLength);
writeUint32(centralHeaderView, 24, fileData.byteLength);
writeUint16(centralHeaderView, 28, fileNameBytes.byteLength);
writeUint16(centralHeaderView, 30, 0);
writeUint16(centralHeaderView, 32, 0);
writeUint16(centralHeaderView, 34, 0);
writeUint16(centralHeaderView, 36, 0);
writeUint32(centralHeaderView, 38, 0);
writeUint32(centralHeaderView, 42, offset);
centralDirectorySections.push(centralHeader, fileNameBytes);
offset += localHeader.byteLength + fileNameBytes.byteLength + fileData.byteLength;
}
const centralDirectory = concatUint8Arrays(centralDirectorySections);
const endRecord = new Uint8Array(22);
const endRecordView = new DataView(endRecord.buffer);
writeUint32(endRecordView, 0, 0x06054B50);
writeUint16(endRecordView, 4, 0);
writeUint16(endRecordView, 6, 0);
writeUint16(endRecordView, 8, entries.length);
writeUint16(endRecordView, 10, entries.length);
writeUint32(endRecordView, 12, centralDirectory.byteLength);
writeUint32(endRecordView, 16, offset);
writeUint16(endRecordView, 20, 0);
return new Blob([
concatUint8Arrays(fileSections),
centralDirectory,
endRecord,
], {
type: 'application/zip',
});
}

View File

@@ -0,0 +1,24 @@
export type TransferRouterMode = 'browser' | 'hash';
export const APP_TRANSFER_ROUTE = '/transfer';
export const PUBLIC_TRANSFER_ROUTE = '/transfer';
export const LEGACY_PUBLIC_TRANSFER_ROUTE = '/t';
export function getTransferRouterMode(mode: string | undefined = import.meta.env?.VITE_ROUTER_MODE): TransferRouterMode {
return mode === 'hash' ? 'hash' : 'browser';
}
export function buildTransferShareUrl(
origin: string,
sessionId: string,
routerMode: TransferRouterMode = 'browser',
) {
const normalizedOrigin = origin.replace(/\/+$/, '');
const encodedSessionId = encodeURIComponent(sessionId);
if (routerMode === 'hash') {
return `${normalizedOrigin}/#${PUBLIC_TRANSFER_ROUTE}?session=${encodedSessionId}`;
}
return `${normalizedOrigin}${PUBLIC_TRANSFER_ROUTE}?session=${encodedSessionId}`;
}

View File

@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createTransferFileManifest,
createTransferCompleteMessage,
createTransferFileCompleteMessage,
createTransferFileId,
createTransferFileManifestMessage,
createTransferFileMetaMessage,
createTransferReceiveRequestMessage,
parseTransferControlMessage,
toTransferChunk,
} from './transfer-protocol';
test('createTransferFileId uses stable file identity parts', () => {
assert.equal(
createTransferFileId({
name: 'report.pdf',
lastModified: 1730000000000,
size: 2048,
}),
'report.pdf-1730000000000-2048',
);
});
test('createTransferFileMetaMessage encodes the control payload for sender and receiver', () => {
const payload = parseTransferControlMessage(
createTransferFileMetaMessage({
id: 'report-1',
name: 'report.pdf',
size: 2048,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
}),
);
assert.deepEqual(payload, {
type: 'file-meta',
id: 'report-1',
name: 'report.pdf',
size: 2048,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
});
});
test('createTransferFileManifest keeps folder relative paths from selected files', () => {
const report = new File(['report'], 'report.pdf', {
type: 'application/pdf',
lastModified: 1730000000000,
});
Object.defineProperty(report, 'webkitRelativePath', {
configurable: true,
value: '课程资料/report.pdf',
});
const manifest = createTransferFileManifest([report]);
assert.deepEqual(manifest, [
{
id: 'report.pdf-1730000000000-6',
name: 'report.pdf',
size: 6,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
},
]);
});
test('createTransferFileManifestMessage and createTransferReceiveRequestMessage stay parseable', () => {
const manifestPayload = parseTransferControlMessage(
createTransferFileManifestMessage([
{
id: 'report-1',
name: 'report.pdf',
size: 2048,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
},
]),
);
assert.deepEqual(manifestPayload, {
type: 'manifest',
files: [
{
id: 'report-1',
name: 'report.pdf',
size: 2048,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
},
],
});
assert.deepEqual(parseTransferControlMessage(createTransferReceiveRequestMessage(['report-1'], true)), {
type: 'receive-request',
fileIds: ['report-1'],
archive: true,
});
});
test('createTransferFileCompleteMessage and createTransferCompleteMessage create parseable control messages', () => {
assert.deepEqual(parseTransferControlMessage(createTransferFileCompleteMessage('report-1')), {
type: 'file-complete',
id: 'report-1',
});
assert.deepEqual(parseTransferControlMessage(createTransferCompleteMessage()), {
type: 'transfer-complete',
});
});
test('parseTransferControlMessage returns null for invalid payloads', () => {
assert.equal(parseTransferControlMessage('{not-json'), null);
});
test('toTransferChunk normalizes ArrayBuffer and Blob data into bytes', async () => {
assert.deepEqual(Array.from(await toTransferChunk(new Uint8Array([1, 2, 3]).buffer)), [1, 2, 3]);
assert.deepEqual(Array.from(await toTransferChunk(new Blob(['hi']))), [104, 105]);
});

View File

@@ -0,0 +1,117 @@
export const TRANSFER_CHUNK_SIZE = 64 * 1024;
export const SIGNAL_POLL_INTERVAL_MS = 1000;
interface TransferFileIdentity {
name: string;
lastModified: number;
size: number;
}
export interface TransferFileDescriptor {
id: string;
name: string;
size: number;
contentType: string;
relativePath: string;
}
export type TransferControlMessage =
{
type: 'manifest';
files: TransferFileDescriptor[];
}
| {
type: 'receive-request';
fileIds: string[];
archive: boolean;
}
| ({
type: 'file-meta';
} & TransferFileDescriptor)
| {
type: 'file-complete';
id: string;
}
| {
type: 'transfer-complete';
};
export function createTransferFileId(file: TransferFileIdentity) {
return `${file.name}-${file.lastModified}-${file.size}`;
}
export function getTransferFileRelativePath(file: File) {
const rawRelativePath = ('webkitRelativePath' in file && typeof file.webkitRelativePath === 'string' && file.webkitRelativePath)
? file.webkitRelativePath
: file.name;
const normalizedPath = rawRelativePath
.replaceAll('\\', '/')
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
.join('/');
return normalizedPath || file.name;
}
export function createTransferFileManifest(files: File[]): TransferFileDescriptor[] {
return files.map((file) => ({
id: createTransferFileId(file),
name: file.name,
size: file.size,
contentType: file.type || 'application/octet-stream',
relativePath: getTransferFileRelativePath(file),
}));
}
export function createTransferFileManifestMessage(files: TransferFileDescriptor[]) {
return JSON.stringify({
type: 'manifest',
files,
} satisfies TransferControlMessage);
}
export function createTransferReceiveRequestMessage(fileIds: string[], archive: boolean) {
return JSON.stringify({
type: 'receive-request',
fileIds,
archive,
} satisfies TransferControlMessage);
}
export function createTransferFileMetaMessage(payload: TransferFileDescriptor) {
return JSON.stringify({
type: 'file-meta',
...payload,
} satisfies TransferControlMessage);
}
export function createTransferFileCompleteMessage(id: string) {
return JSON.stringify({
type: 'file-complete',
id,
} satisfies TransferControlMessage);
}
export function createTransferCompleteMessage() {
return JSON.stringify({
type: 'transfer-complete',
} satisfies TransferControlMessage);
}
export function parseTransferControlMessage(payload: string): TransferControlMessage | null {
try {
return JSON.parse(payload) as TransferControlMessage;
} catch {
return null;
}
}
export async function toTransferChunk(data: ArrayBuffer | Blob) {
if (data instanceof Blob) {
return new Uint8Array(await data.arrayBuffer());
}
return new Uint8Array(data);
}

View File

@@ -0,0 +1,19 @@
export const MAX_TRANSFER_BUFFERED_AMOUNT = 1024 * 1024;
export async function waitForTransferChannelDrain(
channel: RTCDataChannel,
maxBufferedAmount = MAX_TRANSFER_BUFFERED_AMOUNT,
) {
if (channel.bufferedAmount <= maxBufferedAmount) {
return;
}
await new Promise<void>((resolve) => {
const timer = window.setInterval(() => {
if (channel.readyState !== 'open' || channel.bufferedAmount <= maxBufferedAmount) {
window.clearInterval(timer);
resolve();
}
}, 40);
});
}

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
flushPendingRemoteIceCandidates,
handleRemoteIceCandidate,
} from './transfer-signaling';
test('handleRemoteIceCandidate defers candidates until the remote description exists', async () => {
const appliedCandidates: RTCIceCandidateInit[] = [];
const connection = {
remoteDescription: null,
addIceCandidate: async (candidate: RTCIceCandidateInit) => {
appliedCandidates.push(candidate);
},
};
const candidate: RTCIceCandidateInit = {
candidate: 'candidate:1 1 udp 2122260223 10.0.0.2 54321 typ host',
sdpMid: '0',
sdpMLineIndex: 0,
};
const pendingCandidates = await handleRemoteIceCandidate(connection, [], candidate);
assert.deepEqual(appliedCandidates, []);
assert.deepEqual(pendingCandidates, [candidate]);
});
test('flushPendingRemoteIceCandidates applies queued candidates after the remote description is set', async () => {
const appliedCandidates: RTCIceCandidateInit[] = [];
const connection = {
remoteDescription: { type: 'answer' } as RTCSessionDescription,
addIceCandidate: async (candidate: RTCIceCandidateInit) => {
appliedCandidates.push(candidate);
},
};
const pendingCandidates: RTCIceCandidateInit[] = [
{
candidate: 'candidate:1 1 udp 2122260223 10.0.0.2 54321 typ host',
sdpMid: '0',
sdpMLineIndex: 0,
},
{
candidate: 'candidate:2 1 udp 2122260223 10.0.0.3 54322 typ host',
sdpMid: '0',
sdpMLineIndex: 0,
},
];
const remainingCandidates = await flushPendingRemoteIceCandidates(connection, pendingCandidates);
assert.deepEqual(appliedCandidates, pendingCandidates);
assert.deepEqual(remainingCandidates, []);
});

View File

@@ -0,0 +1,32 @@
interface RemoteIceCapableConnection {
remoteDescription: RTCSessionDescription | null;
addIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
}
export async function handleRemoteIceCandidate(
connection: RemoteIceCapableConnection,
pendingCandidates: RTCIceCandidateInit[],
candidate: RTCIceCandidateInit,
) {
if (!connection.remoteDescription) {
return [...pendingCandidates, candidate];
}
await connection.addIceCandidate(candidate);
return pendingCandidates;
}
export async function flushPendingRemoteIceCandidates(
connection: RemoteIceCapableConnection,
pendingCandidates: RTCIceCandidateInit[],
) {
if (!connection.remoteDescription || pendingCandidates.length === 0) {
return pendingCandidates;
}
for (const candidate of pendingCandidates) {
await connection.addIceCandidate(candidate);
}
return [];
}

56
front/src/lib/transfer.ts Normal file
View File

@@ -0,0 +1,56 @@
import { apiRequest } from './api';
import type {
LookupTransferSessionResponse,
PollTransferSignalsResponse,
TransferSessionResponse,
} from './types';
export const DEFAULT_TRANSFER_ICE_SERVERS: RTCIceServer[] = [
{urls: 'stun:stun.cloudflare.com:3478'},
{urls: 'stun:stun.l.google.com:19302'},
];
export function toTransferFilePayload(files: File[]) {
return files.map((file) => ({
name: file.name,
size: file.size,
contentType: file.type || 'application/octet-stream',
}));
}
export function createTransferSession(files: File[]) {
return apiRequest<TransferSessionResponse>('/transfer/sessions', {
method: 'POST',
body: {
files: toTransferFilePayload(files),
},
});
}
export function lookupTransferSession(pickupCode: string) {
return apiRequest<LookupTransferSessionResponse>(
`/transfer/sessions/lookup?pickupCode=${encodeURIComponent(pickupCode)}`,
);
}
export function joinTransferSession(sessionId: string) {
return apiRequest<TransferSessionResponse>(`/transfer/sessions/${encodeURIComponent(sessionId)}/join`, {
method: 'POST',
});
}
export function postTransferSignal(sessionId: string, role: 'sender' | 'receiver', type: string, payload: string) {
return apiRequest<void>(`/transfer/sessions/${encodeURIComponent(sessionId)}/signals?role=${role}`, {
method: 'POST',
body: {
type,
payload,
},
});
}
export function pollTransferSignals(sessionId: string, role: 'sender' | 'receiver', after: number) {
return apiRequest<PollTransferSignalsResponse>(
`/transfer/sessions/${encodeURIComponent(sessionId)}/signals?role=${role}&after=${after}`,
);
}

View File

@@ -16,7 +16,6 @@ export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN';
export interface AdminSummary {
totalUsers: number;
totalFiles: number;
usersWithSchoolCache: number;
}
export interface AdminUser {
@@ -25,8 +24,6 @@ export interface AdminUser {
email: string;
phoneNumber: string | null;
createdAt: string;
lastSchoolStudentId: string | null;
lastSchoolSemester: string | null;
role: AdminUserRole;
banned: boolean;
}
@@ -44,17 +41,6 @@ export interface AdminFile {
ownerEmail: string;
}
export interface AdminSchoolSnapshot {
id: number;
userId: number;
username: string;
email: string;
studentId: string | null;
semester: string | null;
scheduleCount: number;
gradeCount: number;
}
export interface AdminPasswordResetResponse {
temporaryPassword: string;
}
@@ -101,24 +87,50 @@ export interface DownloadUrlResponse {
url: string;
}
export interface CourseResponse {
courseName: string;
teacher: string | null;
classroom: string | null;
dayOfWeek: number | null;
startTime: number | null;
endTime: number | null;
export interface CreateFileShareLinkResponse {
token: string;
filename: string;
size: number;
contentType: string | null;
createdAt: string;
}
export interface GradeResponse {
courseName: string;
grade: number | null;
semester: string | null;
export interface FileShareDetailsResponse {
token: string;
ownerUsername: string;
filename: string;
size: number;
contentType: string | null;
directory: boolean;
createdAt: string;
}
export interface LatestSchoolDataResponse {
studentId: string;
semester: string;
schedule: CourseResponse[];
grades: GradeResponse[];
export interface TransferFileItem {
name: string;
size: number;
contentType: string;
}
export interface TransferSessionResponse {
sessionId: string;
pickupCode: string;
expiresAt: string;
files: TransferFileItem[];
}
export interface LookupTransferSessionResponse {
sessionId: string;
pickupCode: string;
expiresAt: string;
}
export interface TransferSignalEnvelope {
cursor: number;
type: string;
payload: string;
}
export interface PollTransferSignalsResponse {
items: TransferSignalEnvelope[];
nextCursor: number;
}