实现快传,完善快传和网盘的功能,实现文件的互传等一系列功能
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
12
front/src/lib/file-copy.ts
Normal file
12
front/src/lib/file-copy.ts
Normal 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, '/'),
|
||||
},
|
||||
});
|
||||
}
|
||||
12
front/src/lib/file-move.ts
Normal file
12
front/src/lib/file-move.ts
Normal 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, '/'),
|
||||
},
|
||||
});
|
||||
}
|
||||
32
front/src/lib/file-share.test.ts
Normal file
32
front/src/lib/file-share.test.ts
Normal 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');
|
||||
});
|
||||
49
front/src/lib/file-share.ts
Normal file
49
front/src/lib/file-share.ts
Normal 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());
|
||||
}
|
||||
30
front/src/lib/netdisk-paths.test.ts
Normal file
30
front/src/lib/netdisk-paths.test.ts
Normal 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', '/下载'), '/下载');
|
||||
});
|
||||
31
front/src/lib/netdisk-paths.ts
Normal file
31
front/src/lib/netdisk-paths.ts
Normal 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)]);
|
||||
}
|
||||
25
front/src/lib/netdisk-upload.test.ts
Normal file
25
front/src/lib/netdisk-upload.test.ts
Normal 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'), '/下载/相册/旅行');
|
||||
});
|
||||
60
front/src/lib/netdisk-upload.ts
Normal file
60
front/src/lib/netdisk-upload.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
34
front/src/lib/transfer-archive.test.ts
Normal file
34
front/src/lib/transfer-archive.test.ts
Normal 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/);
|
||||
});
|
||||
171
front/src/lib/transfer-archive.ts
Normal file
171
front/src/lib/transfer-archive.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
24
front/src/lib/transfer-links.ts
Normal file
24
front/src/lib/transfer-links.ts
Normal 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}`;
|
||||
}
|
||||
122
front/src/lib/transfer-protocol.test.ts
Normal file
122
front/src/lib/transfer-protocol.test.ts
Normal 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]);
|
||||
});
|
||||
117
front/src/lib/transfer-protocol.ts
Normal file
117
front/src/lib/transfer-protocol.ts
Normal 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);
|
||||
}
|
||||
19
front/src/lib/transfer-runtime.ts
Normal file
19
front/src/lib/transfer-runtime.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
54
front/src/lib/transfer-signaling.test.ts
Normal file
54
front/src/lib/transfer-signaling.test.ts
Normal 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, []);
|
||||
});
|
||||
32
front/src/lib/transfer-signaling.ts
Normal file
32
front/src/lib/transfer-signaling.ts
Normal 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
56
front/src/lib/transfer.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user