├── server ├── meta.db-wal ├── meta.db-shm ├── config.js ├── mime.js ├── provider.js ├── providers │ └── codemao.js ├── db.js └── index.js ├── src ├── types │ ├── shims.d.ts │ ├── dav.d.ts │ └── vendor.d.ts ├── main.js ├── shims-vue.d.ts ├── services │ ├── toast.ts │ ├── http.js │ ├── timeEstimationService.js │ ├── uploadService.ts │ ├── downloadService.ts │ └── chunkUploadService.ts ├── providers │ ├── index.ts │ ├── StorageProvider.ts │ └── CodemaoProvider.ts ├── config │ ├── constants.d.ts │ └── constants.js ├── utils │ ├── env.ts │ ├── helpers.js │ └── storageHelper.ts ├── assets │ ├── webdav.css │ ├── ui.css │ └── darktheme.css ├── components │ ├── DebugLogger.vue │ ├── UploadHistory.vue │ ├── DavPreview.vue │ ├── DavList.vue │ ├── ThemeToggle.vue │ ├── ManagerPage.vue │ ├── WebDavManager.vue │ └── MainContent.vue └── App.vue ├── public ├── app.png ├── App-192.png ├── App-512.png ├── favicon.ico ├── robots.txt ├── FCF.svg └── sitemap.xml ├── .vscode └── extensions.json ├── .editorconfig ├── env.d.ts ├── tsconfig.app.json ├── tsconfig.node.json ├── .idx └── dev.nix ├── eslint.config.js ├── tsconfig.json ├── .gitignore ├── package.json ├── index.html ├── vite.config.ts ├── ROADMAP.md ├── README_CN.md └── README.md /server/meta.db-wal: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/types/shims.d.ts: -------------------------------------------------------------------------------- 1 | // 保留空模块以满足类型系统需求(避免空文件 lint 失败) 2 | export {} 3 | -------------------------------------------------------------------------------- /public/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CJackHwang/Chunkuposs/HEAD/public/app.png -------------------------------------------------------------------------------- /public/App-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CJackHwang/Chunkuposs/HEAD/public/App-192.png -------------------------------------------------------------------------------- /public/App-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CJackHwang/Chunkuposs/HEAD/public/App-512.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CJackHwang/Chunkuposs/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /server/meta.db-shm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CJackHwang/Chunkuposs/HEAD/server/meta.db-shm -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | # 站点地图 5 | Sitemap: https://chunkuposs.cjack.top/sitemap.xml 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "dbaeumer.vscode-eslint", 5 | "EditorConfig.EditorConfig" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}] 2 | charset = utf-8 3 | indent_size = 2 4 | indent_style = space 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from './App.vue'; 3 | import './assets/styles.css'; 4 | import './assets/darktheme.css'; 5 | import 'toastify-js/src/toastify.css'; 6 | createApp(App).mount('body'); 7 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import type { DefineComponent } from 'vue'; 3 | // Use unknown to avoid loosening type checks too much 4 | const component: DefineComponent, Record, unknown>; 5 | export default component; 6 | } 7 | -------------------------------------------------------------------------------- /src/services/toast.ts: -------------------------------------------------------------------------------- 1 | import Toastify from 'toastify-js'; 2 | 3 | export function showToast(message: string) { 4 | Toastify({ 5 | text: message, 6 | duration: 3500, 7 | gravity: 'bottom', 8 | position: 'right', 9 | className: 'm3-toast', 10 | escapeMarkup: false, 11 | }).showToast(); 12 | } 13 | 14 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // Use ESM-style imports instead of triple-slash path refs for shims/vendor 3 | import './src/types/shims.d.ts' 4 | import './src/types/vendor.d.ts' 5 | 6 | declare module '@/utils/env' { 7 | export function getDavBasePath(): string; 8 | export function getDavToken(): string; 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 7 | "baseUrl": ".", 8 | "paths": { 9 | "@/*": ["src/*"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/providers/index.ts: -------------------------------------------------------------------------------- 1 | import type { StorageProvider } from './StorageProvider'; 2 | import { CodemaoProvider } from './CodemaoProvider'; 3 | 4 | export function getDefaultProvider(): StorageProvider { 5 | const name = (import.meta.env.VITE_DEFAULT_PROVIDER || 'codemao').toLowerCase(); 6 | switch (name) { 7 | case 'codemao': 8 | default: 9 | return new CodemaoProvider(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/types/dav.d.ts: -------------------------------------------------------------------------------- 1 | // Shared DAV DTO types for front/back coordination 2 | 3 | export type DavKind = 'single' | 'manifest' | 'unknown' 4 | 5 | export interface DavUploadResponse { 6 | name: string 7 | size: number 8 | manifest?: string 9 | singleUrl?: string 10 | kind: DavKind 11 | } 12 | 13 | export interface DavDeleteResponse { 14 | name: string 15 | link?: string 16 | kind: DavKind 17 | } 18 | 19 | -------------------------------------------------------------------------------- /server/config.js: -------------------------------------------------------------------------------- 1 | // Minimal configuration for WebDAV PoC 2 | export const PORT = Number(process.env.DAV_PORT || 8080); 3 | export const BASE_PATH = process.env.DAV_BASE_PATH || "/dav"; // mount prefix 4 | export const AUTH_TOKEN = process.env.DAV_TOKEN || ""; // optional bearer token 5 | export const RATE_RPS = Number(process.env.DAV_RATE_RPS || 20); // requests per second per ip 6 | export const DATA_DIR = new URL("./data/", import.meta.url); 7 | 8 | -------------------------------------------------------------------------------- /public/FCF.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | https://chunkuposs.cjack.top/ 6 | weekly 7 | 0.8 8 | 9 | 10 | https://chunkuposs.cjack.top/index.html 11 | weekly 12 | 0.8 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/config/constants.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@/config/constants' { 2 | export const UPLOAD_URL: string; 3 | export const REQUEST_RATE_LIMIT: number; 4 | export const CONCURRENT_LIMIT: number; 5 | export const MAX_CHUNK_SIZE: number; 6 | export const MIN_CHUNK_SIZE: number; 7 | export const THIRTY_MB_THRESHOLD: number; 8 | export const BASE_DOWNLOAD_URL: string; 9 | export const FORM_UPLOAD_PATH: string; 10 | export const DOWNLOAD_CONCURRENT_LIMIT: number; 11 | } 12 | 13 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*" 9 | ], 10 | "compilerOptions": { 11 | "noEmit": true, 12 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 13 | 14 | "module": "ESNext", 15 | "moduleResolution": "Bundler", 16 | "types": ["node"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.idx/dev.nix: -------------------------------------------------------------------------------- 1 | {pkgs}: { 2 | channel = "stable-24.05"; 3 | packages = [ 4 | pkgs.nodejs_20 5 | ]; 6 | idx.extensions = [ 7 | "svelte.svelte-vscode" 8 | "vue.volar" 9 | ]; 10 | idx.previews = { 11 | previews = { 12 | web = { 13 | command = [ 14 | "npm" 15 | "run" 16 | "dev" 17 | "--" 18 | "--port" 19 | "$PORT" 20 | "--host" 21 | "0.0.0.0" 22 | ]; 23 | manager = "web"; 24 | }; 25 | }; 26 | }; 27 | } -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import pluginVue from 'eslint-plugin-vue' 2 | import vueTsEslintConfig from '@vue/eslint-config-typescript' 3 | import oxlint from 'eslint-plugin-oxlint' 4 | 5 | export default [ 6 | { 7 | name: 'app/files-to-lint', 8 | files: ['**/*.{ts,mts,tsx,vue}'], 9 | }, 10 | 11 | { 12 | name: 'app/files-to-ignore', 13 | ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', '**/dev-dist/**'], 14 | }, 15 | 16 | ...pluginVue.configs['flat/essential'], 17 | ...vueTsEslintConfig(), 18 | ...oxlint.configs['flat/recommended'], 19 | ] 20 | -------------------------------------------------------------------------------- /src/utils/env.ts: -------------------------------------------------------------------------------- 1 | // Centralized, typed access to Vite envs used across the app 2 | // Non-breaking: preserves defaults currently inlined around the codebase 3 | type ViteEnv = { VITE_DAV_BASE_PATH?: string; VITE_DAV_TOKEN?: string }; 4 | function safeEnv(): ViteEnv { 5 | const m = import.meta as unknown as { env?: ViteEnv }; 6 | return m.env ?? {}; 7 | } 8 | 9 | export function getDavBasePath(): string { 10 | const base = safeEnv().VITE_DAV_BASE_PATH || '/dav'; 11 | return base; 12 | } 13 | 14 | export function getDavToken(): string { 15 | return safeEnv().VITE_DAV_TOKEN || ''; 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "types": ["vite/client"], 12 | "noEmit": true, 13 | "baseUrl": ".", 14 | "paths": { 15 | "@/*": ["src/*"] 16 | } 17 | }, 18 | "include": [ 19 | "src/**/*.ts", 20 | "src/**/*.tsx", 21 | "src/**/*.d.ts", 22 | "src/**/*.vue", 23 | "src/main.js" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/services/http.js: -------------------------------------------------------------------------------- 1 | // 统一的 fetch 重试逻辑(保持现有功能不变) 2 | export async function fetchWithRetry(url, options, retries = 3, backoffMs = 500) { 3 | let attempt = 0; 4 | let lastError; 5 | while (attempt < retries) { 6 | attempt++; 7 | try { 8 | const response = await fetch(url, options); 9 | if (!response.ok) { 10 | lastError = new Error(`请求失败: ${response.status} ${response.statusText}`); 11 | if (attempt >= retries) throw lastError; 12 | } else { 13 | return response; 14 | } 15 | } catch (error) { 16 | lastError = error; 17 | if (attempt >= retries) throw error; 18 | } 19 | await new Promise(r => setTimeout(r, backoffMs * attempt)); 20 | } 21 | throw lastError || new Error('最大重试次数已达到'); 22 | } 23 | 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | *.tsbuildinfo 31 | # 忽略开发构建目录 32 | dev-dist/ 33 | 34 | # 忽略生产构建目录 35 | dist/ 36 | 37 | # 忽略 node_modules 目录 38 | node_modules/ 39 | 40 | # 忽略环境变量文件 41 | .env 42 | .env.local 43 | 44 | # 忽略 IDE 配置文件 45 | .vscode/ 46 | .idea/ 47 | 48 | # 忽略日志文件 49 | *.log 50 | 51 | # 忽略操作系统生成的文件 52 | .DS_Store 53 | Thumbs.db 54 | # WebDAV PoC data directory 55 | server/data/ 56 | server/meta.json 57 | server/meta.db 58 | # Ignore SQLite WAL/SHM alongside meta.db 59 | server/meta.db* 60 | server/*.db* 61 | server/meta.db-shm 62 | -------------------------------------------------------------------------------- /src/providers/StorageProvider.ts: -------------------------------------------------------------------------------- 1 | // 存储提供者通用接口(为后续多渠道与 WebDAV 网关做准备) 2 | // 仅定义接口与类型,不改动现有实现;目前未在组件中引用。 3 | 4 | export type UploadResult = { 5 | url: string; 6 | }; 7 | 8 | export type ChunkUploadOptions = { 9 | path: string; 10 | timeoutMs?: number; 11 | }; 12 | 13 | export type ProviderCapabilities = { 14 | chunkUpload: boolean; 15 | singleUpload: boolean; 16 | }; 17 | 18 | export interface StorageProvider { 19 | /** 提供者名称 */ 20 | name: string; 21 | /** 能力声明 */ 22 | capabilities: ProviderCapabilities; 23 | 24 | /** 单文件上传(≤1MB 或选择不分块) */ 25 | uploadSingle(file: File, options: ChunkUploadOptions): Promise; 26 | 27 | /** 分块上传单块 */ 28 | uploadChunk(chunk: Blob, index: number, options: ChunkUploadOptions): Promise; 29 | 30 | /** 生成分块清单链接(例如:[filename]chunk1,chunk2,...) */ 31 | buildChunkManifest(filename: string, chunkUrls: string[]): string; 32 | 33 | /** 返回下载基础 URL(用于拼接分块标识符) */ 34 | getDownloadBase(): string; 35 | } 36 | -------------------------------------------------------------------------------- /src/assets/webdav.css: -------------------------------------------------------------------------------- 1 | /* WebDAV 管理器布局补充:不覆盖全局视觉(styles.css/ui.css) */ 2 | .dav-container { padding: var(--spacing-2); color: var(--on-surface); } 3 | .dav-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--spacing-2); } 4 | .dav-actions { display: flex; gap: var(--spacing-2); align-items: center; } 5 | .dav-toolbar { display: flex; gap: var(--spacing-2); margin-bottom: var(--spacing-2); } 6 | .dav-content { max-height: 60vh; overflow: auto; } 7 | .dav-empty { padding: var(--spacing-4); color: var(--on-surface-variant); } 8 | .dir-list { display: flex; flex-wrap: wrap; gap: var(--spacing-2); margin: var(--spacing-3) 0; max-height: 40vh; overflow: auto; } 9 | .dir-item { background: var(--surface-container-low); color: var(--on-surface); border: 1px solid var(--outline-variant); border-radius: var(--border-radius-sm); padding: 6px 10px; cursor: pointer; } 10 | .dir-item.active { background: var(--primary-color); color: var(--text-on-primary); border-color: var(--primary-color); } 11 | -------------------------------------------------------------------------------- /src/config/constants.js: -------------------------------------------------------------------------------- 1 | // 集中配置常量(可通过 Vite 环境变量覆盖) 2 | const MB = 1024 * 1024; 3 | 4 | export const UPLOAD_URL = import.meta.env.VITE_UPLOAD_URL || 'https://api.pgaot.com/user/up_cat_file'; 5 | 6 | export const REQUEST_RATE_LIMIT = Number(import.meta.env.VITE_REQUEST_RATE_LIMIT) || 5; // 每秒最大请求数 7 | export const CONCURRENT_LIMIT = Number(import.meta.env.VITE_CONCURRENT_LIMIT) || 2; // 并发分块数 8 | 9 | export const MAX_CHUNK_SIZE = (Number(import.meta.env.VITE_MAX_CHUNK_MB) || 15) * MB; // 15MB 10 | export const MIN_CHUNK_SIZE = (Number(import.meta.env.VITE_MIN_CHUNK_MB) || 1) * MB; // 1MB 11 | export const THIRTY_MB_THRESHOLD = (Number(import.meta.env.VITE_FORCE_CHUNK_MB) || 30) * MB; // 30MB 12 | 13 | export const BASE_DOWNLOAD_URL = (import.meta.env.VITE_BASE_DOWNLOAD_URL || 'https://static.codemao.cn/Chunkuposs/'); 14 | export const FORM_UPLOAD_PATH = import.meta.env.VITE_FORM_UPLOAD_PATH || 'Chunkuposs'; 15 | export const DOWNLOAD_CONCURRENT_LIMIT = Number(import.meta.env.VITE_DOWNLOAD_CONCURRENT_LIMIT) || 4; // 下载并发 16 | -------------------------------------------------------------------------------- /server/mime.js: -------------------------------------------------------------------------------- 1 | const map = { 2 | 'txt': 'text/plain; charset=utf-8', 3 | 'json': 'application/json', 4 | 'html': 'text/html; charset=utf-8', 5 | 'css': 'text/css', 6 | 'js': 'application/javascript', 7 | 'mjs': 'application/javascript', 8 | 'ts': 'application/typescript', 9 | 'png': 'image/png', 10 | 'jpg': 'image/jpeg', 11 | 'jpeg': 'image/jpeg', 12 | 'gif': 'image/gif', 13 | 'svg': 'image/svg+xml', 14 | 'webp': 'image/webp', 15 | 'mp4': 'video/mp4', 16 | 'mov': 'video/quicktime', 17 | 'webm': 'video/webm', 18 | 'pdf': 'application/pdf', 19 | 'zip': 'application/zip', 20 | '7z': 'application/x-7z-compressed', 21 | 'rar': 'application/vnd.rar', 22 | 'gz': 'application/gzip', 23 | 'bz2': 'application/x-bzip2', 24 | // audio 25 | 'mp3': 'audio/mpeg', 26 | 'wav': 'audio/wav', 27 | 'm4a': 'audio/mp4', 28 | 'aac': 'audio/aac', 29 | 'ogg': 'audio/ogg', 30 | 'opus': 'audio/ogg; codecs=opus', 31 | 'flac': 'audio/flac', 32 | } 33 | 34 | export function contentTypeByName(name){ 35 | const idx = name.lastIndexOf('.') 36 | if (idx === -1) return 'application/octet-stream' 37 | const ext = name.slice(idx+1).toLowerCase() 38 | return map[ext] || 'application/octet-stream' 39 | } 40 | -------------------------------------------------------------------------------- /src/components/DebugLogger.vue: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 45 | -------------------------------------------------------------------------------- /src/utils/helpers.js: -------------------------------------------------------------------------------- 1 | // src/utils/helpers.js 2 | 3 | // 复制文本到剪贴板(增加成功/失败回调) 4 | export const copyToClipboard = async (text, successCallback, errorCallback) => { 5 | try { 6 | await navigator.clipboard.writeText(text); 7 | if (successCallback) successCallback(); 8 | } catch (err) { 9 | console.error('复制失败:', err); 10 | if (errorCallback) errorCallback(err); 11 | } 12 | }; 13 | 14 | // 带确认的重置页面 15 | export const resetAll = (confirmationMessage = '确定要刷新网页吗?') => { 16 | if (confirmDanger(confirmationMessage)) { 17 | location.reload(); 18 | } 19 | }; 20 | 21 | // 通用文件下载(支持自定义MIME类型) 22 | export const downloadFile = ( 23 | filename, 24 | content, 25 | mimeType = 'text/plain' 26 | ) => { 27 | const blob = new Blob([content], { type: mimeType }); 28 | const url = URL.createObjectURL(blob); 29 | const a = document.createElement('a'); 30 | a.href = url; 31 | a.download = filename; 32 | document.body.appendChild(a); 33 | a.click(); 34 | document.body.removeChild(a); 35 | URL.revokeObjectURL(url); 36 | return true; // 返回下载状态 37 | }; 38 | 39 | // 危险操作统一确认(可用于清空、删除等) 40 | export const confirmDanger = (message = '此操作不可撤销,确认继续吗?') => { 41 | try { 42 | return window.confirm(message); 43 | } catch { 44 | // SSR 或非浏览环境兜底为 false 45 | return false; 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfd.cjack.file", 3 | "version": "6.1.2", 4 | "private": true, 5 | "type": "module", 6 | "engines": { 7 | "node": ">=20", 8 | "npm": ">=10" 9 | }, 10 | "packageManager": "npm@10.9.2", 11 | "scripts": { 12 | "dev": "vite", 13 | "dev:all": "run-p dev dev:dav", 14 | "dev:dav": "node server/index.js", 15 | "build": "run-p type-check \"build-only {@}\" --", 16 | "preview": "vite preview", 17 | "build-only": "vite build", 18 | "type-check": "vue-tsc --build", 19 | "lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore", 20 | "lint:eslint": "eslint . --fix", 21 | "lint": "run-s lint:*" 22 | }, 23 | "dependencies": { 24 | "@vite-pwa/assets-generator": "^1.0.1", 25 | "ali-oss": "^6.23.0", 26 | "toastify-js": "^1.12.0" 27 | }, 28 | "devDependencies": { 29 | "@tsconfig/node22": "^22.0.2", 30 | "@types/node": "^24.6.1", 31 | "@types/vue": "^2.0.0", 32 | "@vitejs/plugin-legacy": "^7.2.1", 33 | "@vitejs/plugin-react": "^5.0.4", 34 | "@vitejs/plugin-vue": "^6.0.1", 35 | "@vue/eslint-config-typescript": "^14.6.0", 36 | "@vue/tsconfig": "^0.8.1", 37 | "eslint": "^9.36.0", 38 | "eslint-plugin-oxlint": "^1.19.0", 39 | "eslint-plugin-vue": "^10.5.0", 40 | "npm-run-all2": "^8.0.4", 41 | "oxlint": "^1.19.0", 42 | "typescript": "~5.9.3", 43 | "vite": "^7.1.7", 44 | "vite-plugin-pwa": "^1.0.3", 45 | "vite-plugin-vue-devtools": "^8.0.2", 46 | "vue": "^3.5.22", 47 | "vue-tsc": "^3.1.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /server/provider.js: -------------------------------------------------------------------------------- 1 | // Provider registry with env-driven config; default: Codemao 2 | import { getConfigFromEnv as getCodemaoConfig, createProvider as createCodemaoProvider } from './providers/codemao.js'; 3 | 4 | // const MB = 1024 * 1024; // unused 5 | 6 | function env(name, def) { const v = process.env[name]; return (v === undefined || v === '') ? def : v; } 7 | 8 | function getActiveProvider() { 9 | const active = env('DAV_PROVIDER', env('VITE_DEFAULT_PROVIDER', 'codemao')); 10 | switch (active) { 11 | case 'codemao': 12 | default: { 13 | const cfg = getCodemaoConfig(); 14 | return createCodemaoProvider(cfg); 15 | } 16 | } 17 | } 18 | 19 | const PROV = getActiveProvider(); 20 | export const FORCE_CHUNK_MB = PROV.constants.FORCE_CHUNK_MB; 21 | export const MAX_CHUNK_MB = PROV.constants.MAX_CHUNK_MB; 22 | export const MIN_CHUNK_MB = PROV.constants.MIN_CHUNK_MB; 23 | 24 | export async function uploadSingle(buffer, filename, timeoutMs) { 25 | return await PROV.uploadSingle(buffer, filename, timeoutMs); 26 | } 27 | 28 | export async function uploadChunks(buffers, filenamePrefix, timeoutMs) { 29 | return await PROV.uploadChunks(buffers, filenamePrefix, timeoutMs); 30 | } 31 | 32 | export async function uploadChunk(buffer, index, filenamePrefix, timeoutMs) { 33 | return await PROV.uploadChunk(buffer, index, filenamePrefix, timeoutMs); 34 | } 35 | 36 | export function buildChunkManifest(filename, chunkUrls) { 37 | return PROV.buildChunkManifest(filename, chunkUrls); 38 | } 39 | 40 | export function getDownloadBase() { return PROV.getDownloadBase(); } 41 | 42 | export function decideChunking(totalSizeBytes) { 43 | return PROV.decideChunking(totalSizeBytes); 44 | } 45 | 46 | export function splitBuffers(buffer) { 47 | return PROV.splitBuffers(buffer); 48 | } 49 | 50 | export function computeChunkSizeBytes(totalSizeBytes) { 51 | return PROV.computeChunkSizeBytes(totalSizeBytes); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/UploadHistory.vue: -------------------------------------------------------------------------------- 1 | 2 | 30 | 31 | 51 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Chunkuposs — 分块上传与分享 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/providers/CodemaoProvider.ts: -------------------------------------------------------------------------------- 1 | import type { StorageProvider, UploadResult, ChunkUploadOptions, ProviderCapabilities } from '@/providers/StorageProvider'; 2 | import { UPLOAD_URL, FORM_UPLOAD_PATH } from '@/config/constants'; 3 | import { BASE_DOWNLOAD_URL } from '@/config/constants'; 4 | 5 | export class CodemaoProvider implements StorageProvider { 6 | name = 'codemao'; 7 | capabilities: ProviderCapabilities = { chunkUpload: true, singleUpload: true }; 8 | 9 | async uploadSingle(file: File, options: ChunkUploadOptions): Promise { 10 | const formData = new FormData(); 11 | formData.append('file', file, file.name); 12 | formData.append('path', options.path || FORM_UPLOAD_PATH); 13 | const res = await fetch(UPLOAD_URL, { method: 'POST', body: formData, signal: options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined }); 14 | if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); 15 | const data = await res.json(); 16 | if (!data?.url) throw new Error(data?.msg || '服务器响应缺少URL'); 17 | return { url: data.url }; 18 | } 19 | 20 | async uploadChunk(chunk: Blob, index: number, options: ChunkUploadOptions): Promise { 21 | const formData = new FormData(); 22 | // 统一标准:分块对象名使用 `chunk-` 23 | formData.append('file', chunk, `chunk-${index}`); 24 | formData.append('path', options.path || FORM_UPLOAD_PATH); 25 | const res = await fetch(UPLOAD_URL, { method: 'POST', body: formData, signal: options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined }); 26 | if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); 27 | const data = await res.json(); 28 | if (!data?.url) throw new Error(data?.msg || '服务器响应缺少URL'); 29 | return { url: data.url }; 30 | } 31 | 32 | buildChunkManifest(filename: string, chunkUrls: string[]): string { 33 | const ids = chunkUrls.map(u => u.split('?')[0].split('/').pop() || '').join(','); 34 | return `[${encodeURIComponent(filename)}]${ids}`; 35 | } 36 | 37 | getDownloadBase(): string { 38 | return BASE_DOWNLOAD_URL; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/DavPreview.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 39 | 40 | 47 | -------------------------------------------------------------------------------- /src/services/timeEstimationService.js: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | 3 | // 依据已完成分块估算剩余时间;对0除做了防御 4 | export function useTimeEstimation() { 5 | const estimatedCompletionTime = ref(''); 6 | let intervalId = null; 7 | 8 | function updateEstimatedCompletionTimeAfterUpload(startTime, urlsArray, totalChunks) { 9 | const elapsed = Date.now() - startTime; 10 | const completed = Array.isArray(urlsArray) ? urlsArray.filter(url => !!url).length : 0; 11 | const remaining = Math.max(0, totalChunks - completed); 12 | 13 | if (remaining === 0) { 14 | resetEstimatedCompletionTime(); 15 | return; 16 | } 17 | 18 | // 若还没有完成任何分块,不显示 0 秒,改为“正在估算...” 19 | if (completed === 0) { 20 | if (intervalId) clearInterval(intervalId); 21 | estimatedCompletionTime.value = '正在估算...'; 22 | return; 23 | } 24 | 25 | const averageTime = elapsed / completed; // ms/块 26 | let estimatedSeconds = Math.ceil((averageTime * remaining) / 1000); 27 | 28 | // 估算过小(<1s)时,不显示“0秒”闪烁,改为“正在等待服务器响应...” 29 | if (!Number.isFinite(estimatedSeconds) || estimatedSeconds <= 0) { 30 | if (intervalId) clearInterval(intervalId); 31 | estimatedCompletionTime.value = '正在等待服务器响应...'; 32 | return; 33 | } 34 | 35 | if (intervalId) clearInterval(intervalId); 36 | updateTimeDisplay(estimatedSeconds); 37 | 38 | intervalId = setInterval(() => { 39 | if (estimatedSeconds > 1) { 40 | estimatedSeconds--; 41 | updateTimeDisplay(estimatedSeconds); 42 | } else { 43 | clearInterval(intervalId); 44 | estimatedCompletionTime.value = '正在等待服务器响应...'; 45 | } 46 | }, 1000); 47 | } 48 | 49 | function updateTimeDisplay(seconds) { 50 | const minutes = Math.floor(seconds / 60); 51 | const remainingSeconds = seconds % 60; 52 | estimatedCompletionTime.value = `预计完成还需: ${minutes} 分 ${remainingSeconds} 秒`; 53 | } 54 | 55 | function resetEstimatedCompletionTime() { 56 | clearInterval(intervalId); 57 | estimatedCompletionTime.value = ''; 58 | } 59 | 60 | return { 61 | estimatedCompletionTime, 62 | updateEstimatedCompletionTimeAfterUpload, 63 | resetEstimatedCompletionTime 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /src/components/DavList.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 57 | 58 | 64 | -------------------------------------------------------------------------------- /src/services/uploadService.ts: -------------------------------------------------------------------------------- 1 | import { addDebugOutput } from '@/utils/storageHelper'; 2 | import { showToast } from '@/services/toast'; 3 | import { getDefaultProvider } from '@/providers'; 4 | import type { Ref } from 'vue'; 5 | import { FORM_UPLOAD_PATH } from '@/config/constants'; 6 | import { getDavBasePath } from '@/utils/env' 7 | 8 | // 单文件上传(保留原行为) 9 | export async function uploadSingleFile( 10 | file: File, 11 | sjurlRef: Ref, 12 | statusRef: Ref, 13 | uploadHistoryRef: Ref>, 14 | debugOutputRef: Ref 15 | ) { 16 | statusRef.value = '正在上传 (单链接模式)...'; 17 | const formData = new FormData(); 18 | formData.append('file', file, file.name); 19 | formData.append('path', FORM_UPLOAD_PATH); 20 | 21 | try { 22 | const provider = getDefaultProvider(); 23 | const { url } = await provider.uploadSingle(file, { path: FORM_UPLOAD_PATH }); 24 | if (url) { 25 | const id = url.split('?')[0].split('/').pop() || ''; 26 | const idBase = id.replace(/\.[^./]+$/, ''); 27 | const manifestSingle = `[${encodeURIComponent(file.name)}]${idBase}.chunk--1`; 28 | sjurlRef.value = manifestSingle; 29 | statusRef.value = '上传完成 (单链接模式)!'; 30 | addDebugOutput(`单链接模式上传成功(统一清单): ${manifestSingle}`, debugOutputRef); 31 | // 同步到 WebDAV myupload,统一清单格式:[filename]id.chunk--1 32 | try { 33 | const base = getDavBasePath(); 34 | const id = url.split('?')[0].split('/').pop() || ''; 35 | const idBase = id.replace(/\.[^./]+$/, ''); 36 | const manifestSingle = `[${encodeURIComponent(file.name)}]${idBase}.chunk--1`; 37 | await fetch(`${base.replace(/\/$/, '')}/myupload/${encodeURIComponent(file.name)}`, { 38 | method: 'PUT', 39 | headers: { 'Content-Type': 'text/plain' }, 40 | body: manifestSingle, 41 | }); 42 | } catch { /* ignore */ } 43 | // 历史更新在组件中完成,保持职责简单 44 | showToast('上传完成,已生成统一清单'); 45 | } else { 46 | const errorMessage = '服务器返回未知错误'; 47 | showToast(`上传失败 (单链接模式): ${errorMessage}`); 48 | statusRef.value = '上传失败 (单链接模式)'; 49 | addDebugOutput(`处理单链接上传响应失败: ${errorMessage}`, debugOutputRef); 50 | } 51 | } catch (error) { 52 | showToast('单链接模式上传失败,请检查网络或文件大小(≤ 30 MB)'); 53 | statusRef.value = '单链接模式上传失败'; 54 | const msg = error instanceof Error ? error.message : String(error); 55 | addDebugOutput(`单链接模式上传错误: ${msg}`, debugOutputRef); 56 | throw error; 57 | } 58 | } 59 | 60 | // 获取并发上传的活动数(从组件传入以保持状态) 61 | // 组件已不依赖并发/限流工具,这部分逻辑由分块上传服务层内部处理 62 | -------------------------------------------------------------------------------- /src/assets/ui.css: -------------------------------------------------------------------------------- 1 | /* Global UI primitives (Button, Input, Table, Card, Modal) based on M3 variables in styles.css */ 2 | 3 | .ui-btn { 4 | background: var(--primary-color); 5 | color: var(--text-on-primary); 6 | border: 1px solid transparent; 7 | border-radius: var(--border-radius-pill); 8 | padding: 10px var(--spacing-5); 9 | cursor: pointer; 10 | box-shadow: none; 11 | font-size: 0.875rem; 12 | font-weight: 500; 13 | letter-spacing: 0.1px; 14 | height: 40px; 15 | display: inline-flex; 16 | align-items: center; 17 | justify-content: center; 18 | gap: var(--spacing-2); 19 | margin: var(--spacing-1); 20 | min-width: 96px; 21 | transition: background-color var(--duration-medium) var(--ease-standard), 22 | color var(--duration-medium) var(--ease-standard), 23 | border-color var(--duration-medium) var(--ease-standard); 24 | } 25 | .ui-btn:hover { background-color: var(--primary-hover); } 26 | .ui-btn:disabled { opacity: 0.5; cursor: not-allowed; } 27 | .ui-btn--danger { background: var(--error-color); color: var(--md-sys-color-on-error-light); } 28 | .ui-btn--danger:hover { background-color: color-mix(in srgb, var(--error-color) 90%, black 10%); } 29 | .ui-btn--secondary { background: var(--md-sys-color-secondary); color: var(--md-sys-color-on-secondary); } 30 | 31 | .ui-input { 32 | width: 100%; 33 | height: 56px; 34 | border: 1px solid var(--outline-variant); 35 | border-radius: var(--border-radius-lg); 36 | background: var(--surface-container); 37 | padding: var(--spacing-2) var(--spacing-3); 38 | color: var(--text-primary); 39 | font-size: 1rem; 40 | line-height: 1.5; 41 | } 42 | .ui-input:focus { outline: none; border-color: var(--outline-variant); background-color: var(--hover-bg); } 43 | 44 | .ui-card { 45 | background: var(--surface-container); 46 | color: var(--on-surface); 47 | border: 1px solid var(--outline-variant); 48 | border-radius: var(--border-radius-lg); 49 | padding: var(--spacing-4); 50 | } 51 | 52 | .ui-table { width: 100%; border-collapse: collapse; } 53 | .ui-table th, .ui-table td { padding: 10px 12px; border-bottom: 1px solid var(--outline-variant); text-align: left; } 54 | .ui-table th { position: sticky; top: 0; background: var(--surface-container); z-index: 1; } 55 | .ui-table tr:hover { background: var(--hover-bg); } 56 | .ui-table tr.selected { background: var(--surface-container-high); } 57 | 58 | .ui-modal { position: fixed; inset: 0; background: rgba(0,0,0,.35); display: flex; align-items: center; justify-content: center; z-index: 10; } 59 | .ui-modal__card { background: var(--surface-container); color: var(--on-surface); border-radius: var(--border-radius-md); padding: var(--spacing-4); width: min(520px, 90vw); border: 1px solid var(--outline-variant); } 60 | .ui-modal__actions { display: flex; gap: var(--spacing-2); justify-content: flex-end; margin-top: var(--spacing-3); } 61 | .ui-table { width: 100%; border-collapse: collapse; } 62 | .ui-table th, .ui-table td { padding: var(--spacing-3) var(--spacing-4); border-bottom: 1px solid var(--outline-variant); text-align: left; } 63 | .ui-table thead { background: var(--surface-container); } 64 | .ui-table th { position: sticky; top: 0; background: var(--surface-container); z-index: 1; } 65 | .ui-table tbody tr:hover td { background: var(--hover-bg); } 66 | -------------------------------------------------------------------------------- /server/providers/codemao.js: -------------------------------------------------------------------------------- 1 | // Codemao provider module 2 | const MB = 1024 * 1024; 3 | 4 | function env(name, def) { const v = process.env[name]; return (v === undefined || v === '') ? def : v; } 5 | 6 | export function getConfigFromEnv() { 7 | return { 8 | name: 'codemao', 9 | uploadUrl: env('VITE_UPLOAD_URL', 'https://api.pgaot.com/user/up_cat_file'), 10 | formPath: env('VITE_FORM_UPLOAD_PATH', 'Chunkuposs'), 11 | downloadBase: env('VITE_BASE_DOWNLOAD_URL', 'https://static.codemao.cn/Chunkuposs/'), 12 | forceChunkMB: Number(env('VITE_FORCE_CHUNK_MB', '30')), 13 | maxChunkMB: Number(env('VITE_MAX_CHUNK_MB', '15')), 14 | minChunkMB: Number(env('VITE_MIN_CHUNK_MB', '1')), 15 | }; 16 | } 17 | 18 | async function postForm(url, form, timeoutMs) { 19 | const ctrl = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined; 20 | const res = await fetch(url, { method: 'POST', body: form, signal: ctrl }); 21 | if (!res.ok) throw new Error(`HTTP ${res.status}`); 22 | const data = await res.json(); 23 | if (!data?.url) throw new Error('Invalid provider response'); 24 | return data.url; 25 | } 26 | 27 | export function createProvider(cfg) { 28 | return { 29 | name: cfg.name, 30 | getChunkSizeMB() { return cfg.maxChunkMB; }, 31 | decideChunking(totalBytes) { return totalBytes >= (cfg.forceChunkMB * MB) || totalBytes > (1 * MB); }, 32 | getDownloadBase() { return cfg.downloadBase; }, 33 | async uploadSingle(buffer, filename, timeoutMs) { 34 | const form = new FormData(); 35 | form.append('file', new Blob([buffer]), filename); 36 | form.append('path', cfg.formPath); 37 | return await postForm(cfg.uploadUrl, form, timeoutMs); 38 | }, 39 | async uploadChunk(buffer, index, filenamePrefix, timeoutMs) { 40 | const form = new FormData(); 41 | // 统一标准:分块对象名使用 `chunk-`,不携带原始文件名或扩展名 42 | form.append('file', new Blob([buffer]), `chunk-${index}`); 43 | form.append('path', cfg.formPath); 44 | return await postForm(cfg.uploadUrl, form, timeoutMs); 45 | }, 46 | async uploadChunks(buffers, filenamePrefix, timeoutMs) { 47 | const urls = []; 48 | for (let i = 0; i < buffers.length; i++) { 49 | urls.push(await this.uploadChunk(buffers[i], i, filenamePrefix, timeoutMs)); 50 | } 51 | return urls; 52 | }, 53 | buildChunkManifest(filename, chunkUrls) { 54 | const ids = chunkUrls.map(u => u.split('?')[0].split('/').pop() || '').join(','); 55 | return `[${encodeURIComponent(filename)}]${ids}`; 56 | }, 57 | splitBuffers(buffer) { 58 | const size = Math.min(cfg.maxChunkMB * MB, buffer.length); 59 | const chunks = []; 60 | for (let i = 0; i < buffer.length; i += size) { 61 | chunks.push(buffer.subarray(i, Math.min(buffer.length, i + size))); 62 | } 63 | return chunks; 64 | }, 65 | computeChunkSizeBytes(totalSizeBytes) { 66 | if (!totalSizeBytes || totalSizeBytes <= 0) return cfg.minChunkMB * MB; 67 | const half = Math.ceil(totalSizeBytes / 2); 68 | const bounded = Math.max(half, cfg.minChunkMB * MB); 69 | return Math.min(bounded, cfg.maxChunkMB * MB); 70 | }, 71 | constants: { 72 | FORCE_CHUNK_MB: cfg.forceChunkMB, 73 | MAX_CHUNK_MB: cfg.maxChunkMB, 74 | MIN_CHUNK_MB: cfg.minChunkMB, 75 | } 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | import { defineConfig } from 'vite' 3 | import vue from '@vitejs/plugin-vue' 4 | import { VitePWA } from 'vite-plugin-pwa'; 5 | 6 | // Derived via process.env.NODE_ENV in PWA plugin; keep this unused var removed 7 | // https://vite.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | vue({ 11 | // 显式配置 Vue 3 编译器选项 12 | template: { 13 | compilerOptions: { 14 | // 确保使用 Vue 3 的模块系统 15 | whitespace: 'condense', 16 | compatConfig: { MODE: 3 } 17 | } 18 | } 19 | }), 20 | VitePWA({ 21 | // Keep dev SW enabled but derive mode from Vite env 22 | mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', 23 | base: '/', 24 | manifest: { 25 | name: 'Chunkuposs', 26 | short_name: 'Chunkuposs', 27 | theme_color: '#0F1514', 28 | description: '流式分块上传工具,使用编程猫七牛云对象存储接口开发的微云盘', 29 | icons: [ 30 | { 31 | src: '/App.png', 32 | sizes: '1024x1024', 33 | type: 'image/png', 34 | }, 35 | { 36 | src: '/App-192.png', 37 | sizes: '192x192', 38 | type: 'image/png' 39 | }, 40 | { 41 | src: '/App-512.png', 42 | sizes: '512x512', 43 | type: 'image/png' 44 | } 45 | ], 46 | "display": "standalone", 47 | "orientation": "portrait" 48 | }, 49 | registerType: 'autoUpdate', 50 | workbox: { 51 | globPatterns: ['**/*.{js,css,html,ico,png,jpg,svg,woff2,ttf}'], 52 | runtimeCaching: [ 53 | // Ensure WebDAV API calls are never cached or intercepted by SW 54 | { 55 | urlPattern: ({ url }) => url.pathname.startsWith('/dav'), 56 | handler: 'NetworkOnly', 57 | options: { } 58 | }, 59 | // 配置自定义运行时缓存 60 | { 61 | urlPattern: ({ url }) => 62 | url.origin === 'https://ik.imagekit.io', 63 | handler: 'StaleWhileRevalidate', 64 | options: { 65 | cacheName: 'cdn-styles-fonts', 66 | expiration: { 67 | maxEntries: 30, // 最多缓存30个文件 68 | maxAgeSeconds: 30 * 24 * 60 * 60 // 30天 69 | }, 70 | cacheableResponse: { statuses: [200] } 71 | } 72 | }, 73 | { 74 | urlPattern: /\.(?:js|css|html|json|ico|png|jpg|jpeg|svg|woff2|ttf)$/, 75 | handler: 'StaleWhileRevalidate', 76 | options: { 77 | cacheName: 'static-assets', 78 | expiration: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }, 79 | cacheableResponse: { statuses: [200] } 80 | } 81 | } 82 | ] 83 | }, 84 | devOptions: { 85 | enabled: true, 86 | type: 'module', 87 | navigateFallback: 'index.html', 88 | navigateFallbackAllowlist: [/^\/index.html/], // 只允许/index.html触发回退 89 | suppressWarnings: true // 隐藏警告 90 | }, 91 | }), 92 | ], 93 | server: { 94 | // Proxy WebDAV PoC for same-origin during dev 95 | proxy: { 96 | '/dav': { 97 | target: 'http://localhost:8080', 98 | changeOrigin: true, 99 | } 100 | } 101 | }, 102 | optimizeDeps: { 103 | // 显式包含 Vue 3 依赖链 104 | include: [ 105 | 'vue', 106 | '@vue/runtime-core', 107 | '@vue/runtime-dom', 108 | '@vue/shared', 109 | '@vue/reactivity' 110 | ], 111 | // 排除可能引起冲突的库 112 | exclude: ['vue-demi', 'vue-hot-reload-api'] 113 | }, 114 | resolve: { 115 | alias: { 116 | '@': fileURLToPath(new URL('./src', import.meta.url)) 117 | }, 118 | }, 119 | build: { 120 | rollupOptions: { 121 | output: { 122 | // 防止 chunk 循环引用 123 | manualChunks(id) { 124 | if (id.includes('node_modules')) { 125 | return 'vendor' 126 | } 127 | } 128 | } 129 | } 130 | } 131 | }) 132 | -------------------------------------------------------------------------------- /src/types/vendor.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'toastify-js' { 2 | type Options = { 3 | text: string; 4 | duration?: number; 5 | gravity?: 'top' | 'bottom'; 6 | position?: 'left' | 'center' | 'right'; 7 | className?: string; 8 | escapeMarkup?: boolean; 9 | }; 10 | export default function Toastify(opts: Options): { showToast(): void }; 11 | } 12 | 13 | // Re-export actual modules to satisfy TS plugin with alias `@/...` 14 | declare module '@/services/toast' { 15 | export { showToast } from '../services/toast'; 16 | } 17 | 18 | declare module '@/utils/storageHelper' { 19 | export * from '../utils/storageHelper'; 20 | } 21 | 22 | declare module '@/services/uploadService' { 23 | export * from '../services/uploadService'; 24 | } 25 | 26 | declare module '@/services/downloadService' { 27 | export * from '../services/downloadService'; 28 | } 29 | 30 | declare module '@/services/chunkUploadService' { 31 | export * from '../services/chunkUploadService'; 32 | } 33 | 34 | // For constants, rely on env.d.ts typed declaration to avoid JS re-export issues 35 | 36 | // Explicit ambient module declarations to satisfy TS plugin for alias imports 37 | declare module '@/utils/storageHelper' { 38 | export const STORAGE_KEYS: { UPLOAD_LOG: string; UPLOAD_HISTORY: string }; 39 | export type VueRef = { value: T }; 40 | export type HistoryEntry = { time: string; link: string; note?: string }; 41 | export function addDebugOutput(message: string, debugOutputRef: VueRef): void; 42 | export function saveUploadHistory(sjurl: string, uploadHistoryRef: VueRef): void; 43 | export function loadUploadHistory(uploadHistoryRef: VueRef): void; 44 | export function clearLog(debugOutputRef: VueRef): void; 45 | export function clearHistory(uploadHistoryRef: VueRef): void; 46 | export function updateLatestHistoryNote(note: string, uploadHistoryRef: VueRef): void; 47 | export function removeHistoryItem(link: string, uploadHistoryRef: VueRef): void; 48 | export function addHistoryEntry(time: string, link: string, uploadHistoryRef: VueRef, note?: string): void; 49 | export function updateHistoryEntry(originalTime: string, newTime: string, newLink: string, uploadHistoryRef: VueRef, newNote?: string): void; 50 | export function addHistoryDirect(link: string, note?: string): void; 51 | export function removeHistoryDirect(link: string): void; 52 | } 53 | 54 | declare module '@/services/uploadService' { 55 | import type { Ref } from 'vue'; 56 | import type { HistoryEntry, VueRef } from '@/utils/storageHelper'; 57 | export function uploadSingleFile( 58 | file: File, 59 | sjurlRef: Ref, 60 | statusRef: Ref, 61 | uploadHistoryRef: VueRef, 62 | debugOutputRef: Ref 63 | ): Promise; 64 | } 65 | 66 | declare module '@/services/downloadService' { 67 | import type { Ref } from 'vue'; 68 | export function downloadFiles(args: { 69 | sjurlRef: Ref; 70 | statusRef: Ref; 71 | isUploadingRef: Ref; 72 | debugOutputRef: Ref; 73 | downloadProgressRef: Ref; 74 | }): Promise; 75 | } 76 | 77 | declare module '@/services/chunkUploadService' { 78 | import type { Ref } from 'vue'; 79 | import type { HistoryEntry, VueRef } from '@/utils/storageHelper'; 80 | export function uploadChunks(args: { 81 | file: File; 82 | CHUNK_SIZE: number; 83 | totalChunks: number; 84 | debugOutputRef: Ref; 85 | statusRef: Ref; 86 | sjurlRef: Ref; 87 | uploadHistoryRef: VueRef; 88 | updateEstimatedCompletionTimeAfterUpload: (start: number, urls: (string|null)[], total: number) => void; 89 | resetEstimatedCompletionTime: () => void; 90 | }): Promise; 91 | } 92 | 93 | declare module '@/utils/helpers' { 94 | export function copyToClipboard(text: string, successCallback?: () => void, errorCallback?: (err: unknown) => void): Promise; 95 | export function resetAll(confirmationMessage?: string): void; 96 | export function downloadFile(filename: string, content: BlobPart | BlobPart[], mimeType?: string): boolean; 97 | export function confirmDanger(message?: string): boolean; 98 | } 99 | 100 | declare module '@/services/timeEstimationService' { 101 | export function useTimeEstimation(): { 102 | estimatedCompletionTime: import('vue').Ref; 103 | updateEstimatedCompletionTimeAfterUpload: (startTime: number, urlsArray: (string | null)[], totalChunks: number) => void; 104 | resetEstimatedCompletionTime: () => void; 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Chunkuposs Roadmap & Progress 2 | 3 | Goal: Build a pluggable, provider‑driven chunk upload and sharing app, evolving toward a deployable WebDAV gateway (manage links/manifests, do not store files directly). 4 | 5 | 中文 | English below 6 | 7 | ## 愿景与目标 (CN) 8 | - 前端:纯浏览器应用,支持分块上传、分享、分块下载合并、历史与日志。 9 | - Provider 适配层:抽象不同上游渠道,统一上传协议与清单格式。 10 | - WebDAV 网关(规划):将 DAV 操作映射到分块/清单管理,形成轻量网关。 11 | - 合规与安全:不存储内容,遵守上游策略;强调开源与非商业研究用途。 12 | 13 | ## 架构原则 (CN) 14 | - 组件瘦身:组件只做 UI/状态,逻辑在服务层。 15 | - 模块:`services/*`、`providers/*`、`config/constants` + `.env`。 16 | - 可插拔:服务层支持 Provider 注入与扩展。 17 | - 可靠性:并发、限流、动态超时、重试、日志观测。 18 | 19 | ## 版本里程碑 (CN) 20 | - v5.x:Vue3+Vite6;分块上传/分享/下载合并;历史与日志;下载并发限制;ETA 修复;移除 DangBei。 21 | - v6.1.2(当前):Provider 注入式服务层(默认 Codemao);核心服务 TS 化;组件瘦身;文档同步;WebDAV PoC(PROPFIND/PUT/GET/HEAD);Range/HEAD 修复;统一清单格式;外置预览容器。 22 | - v7.0(PoC):WebDAV 原型(Node+Koa/Express+SQLite);映射 DAV 操作;鉴权与限流;前端 `/manager` 管理页。 23 | - v7.x+:Provider Registry;断点续传;性能/兼容优化;模块测试。 24 | 25 | ## 当前进度 (CN) 26 | - 常量集中与 `.env` 覆盖;下载并发限制(默认 4)。 27 | - 服务层:上传/分块/下载/ETA 修复;Provider 全量接入;单链/分块清单统一格式。 28 | - WebDAV PoC:实现 PROPFIND/PUT/GET/HEAD;GET 支持 Range;HEAD 预估 `Content-Length`;清单大小计算支持 `.chunk--1` 扩展名恢复。 29 | - 管理器:外置 `DavPreview` 容器置于管理器下方,不受列表滚动影响;忽略 `server/*.db*` 防止提交本地数据库。 30 | - 组件瘦身:MainContent 仅做状态与调用;下载进度条;历史/日志统一。 31 | 32 | ## 下一阶段 (CN) 33 | 1. WebDAV PoC:完善 DAV 映射与错误处理;鉴权与限流配置化。 34 | 2. 管理页:批量/重命名/移动交互完善;清单/单链元信息展示优化。 35 | 3. Provider 扩展:鉴权/签名与更多上游;Provider 选择与注册。 36 | 4. 测试与质量:分块/下载/ETA/限流模块测试;端到端预览/Range 测试;统一错误码与日志。 37 | 5. 文档:部署与使用完善;与 README/ROADMAP 同步更新。 38 | 39 | ## 风险与决策 (CN) 40 | - Provider 可用性与合规;避免硬编码与不合规使用。 41 | - 网关边界清晰;避免过度耦合与状态持久化。 42 | - TS 化节奏渐进;优先核心服务与 Provider。 43 | - 浏览器内存/并发策略与多端兼容测试。 44 | 45 | --- 46 | 47 | ## Vision & Goals (EN) 48 | - Frontend: pure browser app with chunked upload, sharing, chunked download merge, history and logs. 49 | - Provider layer: abstracts storage channels; unified upload protocol and manifest format. 50 | - WebDAV gateway (future): map DAV ops to chunking/manifest; lightweight deployable gateway. 51 | - Compliance & safety: do not store content; follow upstream policies; open‑source, non‑commercial. 52 | 53 | ## Architecture Principles (EN) 54 | - Thin components: UI/state only; logic in services. 55 | - Modules: `services/*`, `providers/*`, `config/constants` + `.env`. 56 | - Pluggable: service layer supports provider injection/extension. 57 | - Reliability: concurrency, rate‑limit, dynamic timeouts, retry, logs. 58 | 59 | ## Milestones (EN) 60 | - v5.x: Vue3+Vite6; chunk upload/share/download merge; history/logs; download concurrency; ETA fix; remove DangBei. 61 | - v6.0 (current): provider‑injected services (default Codemao); TS services; component slimming; docs sync. 62 | - v7.0 (PoC): WebDAV prototype (Node+Koa/Express+SQLite); map DAV ops; auth/rate‑limit; `/manager` frontend. 63 | - v7.x+: provider registry; resume; perf/compat optimizations; module tests. 64 | 65 | ## Current Progress (EN) 66 | - Centralized constants and `.env` overrides; download concurrency (default 4). 67 | - Services: upload/chunk/download/ETA fix; full provider integration; unified single/chunk manifest format. 68 | - WebDAV PoC: PROPFIND/PUT/GET/HEAD implemented; GET supports Range; HEAD preflights `Content-Length`; manifest size calc supports `.chunk--1` extension recovery. 69 | - Manager: external `DavPreview` container rendered below manager; ignore `server/*.db*` to avoid committing local DB. 70 | - Components: MainContent handles UI/state and calls; download progress; unified history/logs. 71 | 72 | ## Next Phase (EN) 73 | 1. WebDAV PoC: improve DAV mapping/error handling; configurable auth/rate‑limit. 74 | 2. Manager page: refine bulk/rename/move interactions; show manifest/single metadata. 75 | 3. Provider expansion: auth/signature/more upstreams; selection/registry. 76 | 4. Tests & quality: module tests for chunk/download/ETA/rate‑limit; end‑to‑end preview/Range tests; unify error codes/logs. 77 | 5. Docs: deployment/usage; keep README/ROADMAP in sync. 78 | 79 | ### Link Format (Unified) 80 | - Single: `[filename]ID.chunk--1` → replace `.chunk--1` with the filename extension; prepend `downloadBase`. 81 | - Chunked: `[filename]ID0,ID1,...` → stream in order for non‑range; map ranges across chunks. 82 | 83 | ## Risks & Decisions (EN) 84 | - Provider availability/compliance; avoid hard‑coded creds and misuse. 85 | - Clear gateway scope; avoid tight coupling and state persistence. 86 | - Gradual TS adoption; prioritize core services/providers. 87 | - Browser memory/concurrency strategies; multi‑device compatibility tests. 88 | 89 | --- 90 | 91 | ## Why TypeScript 92 | - Types clarify contracts between services/providers; catch errors earlier. 93 | - Maintainability and refactoring safety with IDE support. 94 | - Testability: explicit inputs/outputs; easier provider mocking. 95 | - Gradual strategy: prioritize `services/*` and `providers/*`; components later. 96 | -------------------------------------------------------------------------------- /src/components/ThemeToggle.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 58 | 59 | 98 | -------------------------------------------------------------------------------- /src/components/ManagerPage.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 138 | 139 | 161 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 109 | 110 | 177 | -------------------------------------------------------------------------------- /server/db.js: -------------------------------------------------------------------------------- 1 | import { execFileSync } from 'node:child_process'; 2 | import { fileURLToPath } from 'node:url'; 3 | import { dirname } from 'node:path'; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | const DB_FILE = new URL('./meta.db', import.meta.url); 8 | 9 | function normalizePath(p) { 10 | let s = decodeURIComponent(p || '/'); 11 | if (!s.startsWith('/')) s = '/' + s; 12 | s = s.replace(/\/+/, '/'); 13 | return s; 14 | } 15 | function ensureDirPath(p) { const s = normalizePath(p); return s.endsWith('/') ? s : s + '/'; } 16 | function getParent(p) { 17 | const s = normalizePath(p); 18 | if (s === '/') return '/'; 19 | const parts = s.split('/').filter(Boolean); 20 | parts.pop(); 21 | if (parts.length === 0) return '/'; 22 | return '/' + parts.join('/') + '/'; 23 | } 24 | 25 | let useSqlite = false; 26 | 27 | function sqliteExec(sql) { 28 | try { 29 | const out = execFileSync('sqlite3', [fileURLToPath(DB_FILE), sql], { encoding: 'utf-8' }); 30 | return out; 31 | } catch { 32 | return null; 33 | } 34 | } 35 | 36 | export function loadStore() { 37 | // Initialize sqlite or throw 38 | const init = sqliteExec(`PRAGMA journal_mode=WAL;\nCREATE TABLE IF NOT EXISTS entries (path TEXT PRIMARY KEY, type TEXT, name TEXT, size INTEGER, mtime TEXT, singleUrl TEXT, manifest TEXT, chunkUrls TEXT, chunkLengths TEXT);`); 39 | // Migrate legacy schema if needed (add missing columns) 40 | const info = sqliteExec(`PRAGMA table_info(entries);`) || ''; 41 | if (!/\|chunkLengths\|/i.test(info)) { 42 | sqliteExec(`ALTER TABLE entries ADD COLUMN chunkLengths TEXT;`); 43 | } 44 | if (init === null) { 45 | throw new Error('sqlite3 CLI is required but not available in this environment'); 46 | } 47 | useSqlite = true; 48 | sqliteExec(`INSERT OR IGNORE INTO entries(path,type,name,mtime) VALUES ('/','dir','/',datetime('now'));`); 49 | sqliteExec(`INSERT OR IGNORE INTO entries(path,type,name,mtime) VALUES ('/myupload/','dir','myupload',datetime('now'));`); 50 | return { entries: {} }; 51 | } 52 | 53 | export function saveStore() { /* no-op for sqlite */ } 54 | 55 | export function getEntry(store, p) { 56 | const key = normalizePath(p); 57 | if (useSqlite) { 58 | const out = sqliteExec(`SELECT path,type,name,size,mtime,singleUrl,manifest,chunkUrls,chunkLengths FROM entries WHERE path='${key.replace(/'/g, "''")}';`); 59 | if (!out) return undefined; 60 | const row = out.trim().split('|'); 61 | if (!row[0]) return undefined; 62 | const [_path, type, name, size, mtime, singleUrl, manifest, chunkUrls, chunkLengths] = row; 63 | return { type, name, size: size? Number(size): undefined, mtime, singleUrl: singleUrl||undefined, manifest: manifest||undefined, chunkUrls: chunkUrls? chunkUrls.split(','): undefined, chunkLengths: chunkLengths? chunkLengths.split(',').map(n=>Number(n)): undefined }; 64 | } 65 | return undefined; 66 | } 67 | 68 | export function listChildren(store, dirPath) { 69 | const base = ensureDirPath(dirPath); 70 | if (useSqlite) { 71 | const out = sqliteExec(`SELECT path,type,name,size,mtime,singleUrl,manifest,chunkUrls,chunkLengths FROM entries WHERE path LIKE '${base.replace(/'/g, "''")}%' AND path!='${base.replace(/'/g, "''")}';`); 72 | const lines = (out || '').trim().split('\n').filter(Boolean); 73 | const results = lines.map(line => { 74 | const [path, type, name, size, mtime, singleUrl, manifest, chunkUrls, chunkLengths] = line.split('|'); 75 | return { path, entry: { type, name, size: size? Number(size): undefined, mtime, singleUrl: singleUrl||undefined, manifest: manifest||undefined, chunkUrls: chunkUrls? chunkUrls.split(','): undefined, chunkLengths: chunkLengths? chunkLengths.split(',').map(n=>Number(n)): undefined } }; 76 | }).filter(({ path }) => getParent(path) === base); 77 | return results; 78 | } 79 | return []; 80 | } 81 | 82 | export function makeDir(store, dirPath) { 83 | const p = ensureDirPath(dirPath); 84 | if (useSqlite) { 85 | sqliteExec(`INSERT OR REPLACE INTO entries(path,type,name,mtime) VALUES ('${p.replace(/'/g, "''")}','dir','${p.split('/').filter(Boolean).pop()}',datetime('now'));`); 86 | return; 87 | } 88 | // sqlite-only; already handled 89 | } 90 | 91 | export function setFile(store, filePath, fileInfo) { 92 | const p = normalizePath(filePath); 93 | if (useSqlite) { 94 | const chunkStr = fileInfo.chunkUrls ? fileInfo.chunkUrls.join(',') : null; 95 | const lensStr = fileInfo.chunkLengths ? fileInfo.chunkLengths.join(',') : null; 96 | sqliteExec(`INSERT OR REPLACE INTO entries(path,type,name,size,mtime,singleUrl,manifest,chunkUrls,chunkLengths) VALUES ('${p.replace(/'/g, "''")}','file','${fileInfo.name}','${fileInfo.size||0}',datetime('now'),'${(fileInfo.singleUrl||'').replace(/'/g, "''")}','${(fileInfo.manifest||'').replace(/'/g, "''")}','${(chunkStr||'').replace(/'/g, "''")}','${(lensStr||'').replace(/'/g, "''")}');`); 97 | return; 98 | } 99 | // sqlite-only; already handled 100 | } 101 | 102 | export function removeEntry(store, p) { 103 | const key = normalizePath(p); 104 | if (useSqlite) { 105 | const base = ensureDirPath(key); 106 | sqliteExec(`DELETE FROM entries WHERE path='${key.replace(/'/g, "''")}';`); 107 | // also delete children if dir 108 | sqliteExec(`DELETE FROM entries WHERE path LIKE '${base.replace(/'/g, "''")}%' AND path!='${base.replace(/'/g, "''")}';`); 109 | return; 110 | } 111 | // sqlite-only; already handled 112 | } 113 | 114 | export function moveEntry(store, from, to) { 115 | const src = normalizePath(from); 116 | const dst = normalizePath(to); 117 | if (useSqlite) { 118 | const srcEntry = getEntry(store, src); 119 | if (!srcEntry) return false; 120 | const isDir = srcEntry.type === 'dir'; 121 | if (!isDir) { 122 | // simple move 123 | sqliteExec(`UPDATE entries SET path='${dst.replace(/'/g, "''")}', name='${dst.split('/').filter(Boolean).pop()}', mtime=datetime('now') WHERE path='${src.replace(/'/g, "''")}';`); 124 | return true; 125 | } 126 | // move dir and children by prefix replacement 127 | const base = ensureDirPath(src); 128 | const newBase = ensureDirPath(dst); 129 | const out = sqliteExec(`SELECT path FROM entries WHERE path LIKE '${base.replace(/'/g, "''")}%' ORDER BY path;`); 130 | const lines = (out || '').trim().split('\n').filter(Boolean); 131 | for (const path of lines) { 132 | const newPath = (newBase + path.slice(base.length)).replace(/\/+/, '/'); 133 | const newName = newPath.split('/').filter(Boolean).pop(); 134 | sqliteExec(`UPDATE entries SET path='${newPath.replace(/'/g, "''")}', name='${newName}', mtime=datetime('now') WHERE path='${path.replace(/'/g, "''")}';`); 135 | } 136 | // ensure dir row exists 137 | sqliteExec(`INSERT OR REPLACE INTO entries(path,type,name,mtime) VALUES ('${ensureDirPath(dst).replace(/'/g, "''")}','dir','${dst.split('/').filter(Boolean).pop()}',datetime('now'));`); 138 | return true; 139 | } 140 | return false; 141 | } 142 | 143 | export const utils = { normalizePath, ensureDirPath, getParent }; 144 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | 2 | # Chunkuposs — 分块上传与分享 (v6.1.2) 3 | 4 | [![GitHub License](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat)](https://www.gnu.org/licenses/gpl-3.0.html) 5 | [![Vue 3](https://img.shields.io/badge/Vue.js-3.5%2B-brightgreen?logo=vue.js)](https://vuejs.org/) 6 | [![Vercel Deployment](https://img.shields.io/badge/Deploy%20on-Vercel-black?logo=vercel)](https://vercel.com) 7 | [![Node.js](https://img.shields.io/badge/Node.js-%E2%89%A520%2F22-brightgreen?logo=node.js)](https://nodejs.org/) 8 | [![npm](https://img.shields.io/badge/npm-%E2%89%A510-red?logo=npm)](https://www.npmjs.com/) 9 | [![Vite](https://img.shields.io/badge/Vite-7.x-646cff?logo=vite)](https://vitejs.dev/) 10 | [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?logo=typescript)](https://www.typescriptlang.org/) 11 | 中文 | English: [README.md](README.md) 12 | 13 | Chunkuposs 采用 Provider 架构(默认编程猫 CodeMao),核心服务使用 TypeScript 实现。DangBei 集成已移除。 14 | 新增 WebDAV 管理器的外置预览容器(DavPreview):预览卡片作为独立容器渲染在整个管理器下方,不受文件列表滚动影响。 15 | 16 | ## 6.0+ 更新要点 17 | - Provider 注入式服务层完善(`StorageProvider` + `CodemaoProvider`)。 18 | - 核心服务 TS 化(上传/分块/下载/toast/存储)。 19 | - 编程猫模式下 >30 MB 强制分块;分块动态范围 1–15 MB。 20 | - 流式切割 + 并发(上传并发 2 + ≤5 次/秒)。 21 | - 下载并发(默认 4)与进度展示。 22 | - ETA 更智能、动态超时、重试与详尽日志。 23 | - 支持 `?url=...` 分享链接与页面自动解析;历史统一。 24 | - Vite 7 + PWA 插件;支持 Vue DevTools 开发插件。 25 | 26 | ## 核心功能 27 | - Provider 架构:`StorageProvider` 接口 + 默认 `CodemaoProvider`。 28 | - TS 服务层:`uploadService.ts`、`chunkUploadService.ts`、`downloadService.ts`、`toast.ts`、`storageHelper.ts`。 29 | - 智能分块:1–15 MB 动态;>30 MB 强制分块;流式切割。 30 | - 并发与限流:上传并发 2 + ≤5 次/秒;下载并发 4(带进度条)。 31 | - 可靠性:动态超时、重试、ETA 优化。 32 | - 分享与历史:`?url=...` 分享链接,本地历史与日志统一。 33 | 34 | ## 技术栈 35 | - UI 与样式:Vue 3、CSS 变量、Toastify.js、Material Design 3。 36 | - 网络:`fetch` + `AbortController`;Provider 驱动端点。 37 | - 文件处理:Streams API + Blob 合并(浏览器内存优化)。 38 | - 状态:Vue 响应式(`ref`、`computed`)+ `localStorage` 持久化。 39 | - 构建:Vite 7 + PWA 插件,vendor 拆分。 40 | 41 | 版本信息(当前) 42 | - Vite 7.1.x、@vitejs/plugin-vue 6.0.x、vite-plugin-pwa 1.0.x 43 | - Vue 3.5.x、TypeScript 5.9.x、vue-tsc 3.x 44 | - ESLint 9.x、Oxlint 1.x 45 | 46 | ## 快速开始 47 | - 开发:`npm install`,`npm run dev` 48 | - 构建:`npm run build` 49 | - 预览:`npm run preview` 50 | 51 | ### 运行要求 52 | - Node.js ≥20(推荐 22) 53 | - npm ≥10(或使用 pnpm/yarn;示例为 npm) 54 | - 支持 Streams API 的现代浏览器 55 | - 已在 `package.json` 的 `engines` 与 `packageManager` 字段声明运行环境 56 | 57 | ### 脚本说明 58 | - `npm run dev`:启动 Vite 开发服务器 59 | - `npm run dev:dav`:启动最小 WebDAV 原型服务器(Node >=20) 60 | - `npm run build`:类型检查 + 构建(含 PWA) 61 | - `npm run preview`:预览生产构建 62 | - `npm run type-check`:`vue-tsc` 类型检查 63 | - `npm run lint`:同时运行 ESLint 与 Oxlint 64 | - `npm run lint:eslint`:仅 ESLint 修复 65 | - `npm run lint:oxlint`:仅 Oxlint 修复 66 | 67 | 环境变量(`.env`/`.env.local`): 68 | ``` 69 | VITE_UPLOAD_URL=https://api.pgaot.com/user/up_cat_file 70 | VITE_REQUEST_RATE_LIMIT=5 71 | VITE_CONCURRENT_LIMIT=2 72 | VITE_MAX_CHUNK_MB=15 73 | VITE_MIN_CHUNK_MB=1 74 | VITE_FORCE_CHUNK_MB=30 75 | VITE_BASE_DOWNLOAD_URL=https://static.codemao.cn/Chunkuposs/ 76 | VITE_FORM_UPLOAD_PATH=Chunkuposs 77 | VITE_DOWNLOAD_CONCURRENT_LIMIT=4 78 | ``` 79 | 80 | 说明: 81 | - `VITE_FORCE_CHUNK_MB`:在编程猫模式下,大于该值强制启用分块上传。 82 | - `VITE_BASE_DOWNLOAD_URL`:用于拼接分块下载 URL 的基础前缀。 83 | - 所有变量均有默认值,见 `src/config/constants.js`。 84 | 85 | Vercel 一键部署: 86 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/CJackHwang/Chunkuposs) 87 | 88 | ## 使用流程 89 | - 选择文件 → 上传 90 | - 监控进度(分块或单链接) 91 | - 获取链接:分块格式 `[文件名]块1,块2,...` 或单链接 URL 92 | - 下载:粘贴分块链接或标准 URL 93 | - 分享:复制 `?url=...` 链接 94 | - 管理器:点击顶部“WebDAV 文件管理器”或访问 `#/dav`,在页面下方的独立预览容器中查看选中文件的链接与图片/音视频预览。 95 | 96 | ### 链接格式 97 | - 单链接(≤1 MB 或手动选择单次上传):`https://.../path/file.ext` 98 | - 分块清单:`[文件名]id1,id2,id3` 99 | - 文件名使用 URL 编码;id 从上传响应中抽取。 100 | 101 | ## 配置与 Provider 102 | - 核心常量:上传 URL、限速/并发、分块阈值、下载基础 URL、表单上传路径。 103 | - Provider 层: 104 | - `src/providers/StorageProvider.ts`:`uploadSingle`、`uploadChunk`、`buildChunkManifest`、`getDownloadBase` 105 | - `src/providers/CodemaoProvider.ts`:默认编程猫实现 106 | 107 | ### 关键逻辑片段(MainContent.vue) 108 | ``` 109 | // >30MB 强制分块 110 | if (uploadMode.value === 'codemao' && fileSize > THIRTY_MB_THRESHOLD) { 111 | isLargeFileSupport.value = true; 112 | isChunkCheckboxDisabled.value = true; 113 | } 114 | 115 | // 分享链接生成 116 | const currentUrl = new URL(window.location.href); 117 | currentUrl.search = ''; 118 | currentUrl.searchParams.set('url', encodeURIComponent(sjurl.value)); 119 | const shareUrl = currentUrl.toString(); 120 | ``` 121 | 122 | ## 架构 123 | ```mermaid 124 | flowchart TD 125 | subgraph 输入与模式 126 | A[文件输入] 127 | M[Provider:编程猫] 128 | end 129 | A --> P{文件大小} 130 | M --> P 131 | subgraph 编程猫 OSS 132 | P -->|>1MB| B{分块?} 133 | B -->|>30MB| BF[强制分块] --> D 134 | B -->|1MB < 大小 <= 30MB 且分块| D[流式切割] 135 | B -->|<=1MB 或关闭| C[单次 FormData] 136 | D --> E[Uint8Array 缓冲] 137 | E --> F[并发 2 + 5/s] 138 | F --> G[Provider 上传 + 重试] 139 | G --> H[URL 聚合] 140 | H --> I[清单: 文件名 + 分块ID] 141 | C --> S[单链接] --> J[展示/存储] 142 | I --> J 143 | end 144 | J --> DL[下载] 145 | DL --> T{类型?} 146 | T -->|分块| MZ[并发获取+合并] --> SV[保存] 147 | T -->|标准| OP[新标签打开] 148 | J --> SH[分享] --> L[?url=...] --> CB[剪贴板] 149 | J --> LS[localStorage] 150 | ``` 151 | 152 | ## 分块原理 153 | - 通过 `ReadableStream` 读取文件并缓冲到 `Uint8Array` 分块。 154 | - 上传并发限制为 2,另有全局 ≤5 次/秒的限速。 155 | - 每个分块上传采用动态超时与指数退避重试。 156 | - 成功后聚合分块 id 生成 `[filename]id1,id2,...` 清单。 157 | - 下载按并发(默认 4)拉取所有分块,合并 Blob 后触发保存。 158 | 159 | ## 项目结构 160 | - `src/components/MainContent.vue`:UI 状态与服务调用 161 | - `src/services/*`:单/分块上传、下载、ETA、toast 162 | - `src/providers/*`:Provider 接口与编程猫默认实现 163 | - `src/config/constants.*`:运行时配置与环境覆盖 164 | - `src/utils/*`:工具与 localStorage 管理 165 | - `vite.config.ts`:Vite + PWA 配置 166 | - `src/components/WebDavManager.vue`:WebDAV 管理器(文件列表与操作) 167 | - `src/components/DavPreview.vue`:WebDAV 外置预览容器(固定在管理器下方) 168 | 169 | ## PWA 170 | - 已配置 manifest(名称/图标/主题);独立应用显示。 171 | - 运行时缓存常见静态资源;开发环境开启 PWA 测试。 172 | 173 | ## 限制与说明 174 | - 受上游服务策略影响;链接可能受限或过期。 175 | - 超大文件取决于浏览器内存与网络稳定性。 176 | - 禁止将非公开 API 用于商业用途。 177 | 178 | ## 安全与许可 179 | - 数据隐私:日志与历史仅存储在浏览器 `localStorage`。 180 | - 服务策略:上传遵循上游存储服务规则。 181 | - 许可协议:GPL‑3.0;禁止将非公开 API 用于商业用途。 182 | 183 | ## 组件与服务 184 | - 组件:`MainContent.vue`、`DebugLogger.vue`、`UploadHistory.vue`、`ThemeToggle.vue`。 185 | - 服务(TS):`uploadService.ts`、`chunkUploadService.ts`、`downloadService.ts`、`toast.ts`。 186 | - 工具:`helpers.js`、`storageHelper.ts`。 187 | - Provider:`StorageProvider.ts`、`CodemaoProvider.ts`、`providers/index.ts`。 188 | 189 | ## 贡献 190 | - 遵循 Vue 3 ` 355 | 356 | 357 | -------------------------------------------------------------------------------- /src/components/MainContent.vue: -------------------------------------------------------------------------------- 1 | 98 | 99 | 533 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | // WebDAV Provider-backed Gateway (no file contents stored on relay) 2 | import http from 'node:http'; 3 | import { dirname } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import crypto from 'node:crypto'; 6 | import { PORT, BASE_PATH, RATE_RPS } from './config.js'; 7 | import { loadStore, saveStore, listChildren, makeDir, setFile, getEntry, removeEntry, moveEntry, utils } from './db.js'; 8 | import { uploadSingle, uploadChunk, buildChunkManifest, getDownloadBase, computeChunkSizeBytes, MAX_CHUNK_MB } from './provider.js'; 9 | import { contentTypeByName } from './mime.js'; 10 | 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = dirname(__filename); 13 | 14 | // No auth (PoC as requested) 15 | function checkAuth() { return true; } 16 | 17 | // Very small token-bucket per IP 18 | const buckets = new Map(); 19 | setInterval(() => { 20 | for (const b of buckets.values()) b.tokens = Math.min(b.tokens + RATE_RPS, RATE_RPS); 21 | }, 1000); 22 | function rateLimit(req, res) { 23 | const ip = (req.socket.remoteAddress || 'unknown'); 24 | let b = buckets.get(ip); 25 | if (!b) { b = { tokens: RATE_RPS }; buckets.set(ip, b); } 26 | if (b.tokens <= 0) { res.writeHead(429); res.end('Too Many Requests'); return false; } 27 | b.tokens -= 1; return true; 28 | } 29 | 30 | function send(res, code, headers, body) { 31 | // 防止在已发送响应头后再次写入导致 ERR_HTTP_HEADERS_SENT 32 | if (res.headersSent) { 33 | try { res.end(); } catch { /* noop */ } 34 | return; 35 | } 36 | res.writeHead(code, headers); 37 | if (body) res.end(body); else res.end(); 38 | } 39 | 40 | function pathFromUrl(urlPath) { 41 | let p = decodeURIComponent(urlPath || '/'); 42 | if (!p.startsWith(BASE_PATH)) return null; 43 | p = p.slice(BASE_PATH.length); 44 | if (!p || p === '/') return '/'; 45 | return utils.normalizePath(p); 46 | } 47 | 48 | function davHref(path, isDir) { 49 | const base = BASE_PATH + utils.normalizePath(path); 50 | return isDir ? (base.endsWith('/') ? base : base + '/') : base; 51 | } 52 | 53 | function handleOptions(req, res) { 54 | send(res, 200, { 55 | 'DAV': '1,2', 56 | 'Allow': 'OPTIONS, PROPFIND, MKCOL, PUT, GET, DELETE, MOVE', 57 | 'Accept-Ranges': 'bytes', 58 | }); 59 | } 60 | 61 | function xmlEscape(s) { return s.replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c])); } 62 | 63 | function makeETag(entry) { 64 | const base = `${entry.name || ''}|${entry.size || 0}|${entry.singleUrl || ''}|${entry.manifest || ''}|${entry.mtime || ''}`; 65 | const h = crypto.createHash('sha256').update(base).digest('hex').slice(0, 16); 66 | return `W/"${h}"`; 67 | } 68 | 69 | // Normalize manifest ids to unified form: id.chunk- 70 | function normalizeManifest(manifest) { 71 | try { 72 | const m = (manifest || '').trim(); 73 | const prefix = (m.match(/^\[[^\]]+\]/) || [''])[0]; 74 | const idsStr = m.replace(/^\[[^\]]+\]/, ''); 75 | const ids = idsStr.split(',').filter(Boolean).map(id => id.replace(/\.[^.]+-chunk-(\d+)$/i, '.chunk-$1')); 76 | return prefix + ids.join(','); 77 | } catch { return manifest; } 78 | } 79 | 80 | function propfind(store, vpath, depth = 0) { 81 | const now = new Date().toUTCString(); 82 | function entry(href, name, isDir, size, mtime, etag, ctype, extraProps = '') { 83 | return ` 84 | 85 | ${href} 86 | 87 | 88 | ${xmlEscape(name)} 89 | ${isDir ? '' : ''} 90 | ${isDir ? 0 : size} 91 | ${mtime || now} 92 | ${isDir ? '' : `${xmlEscape(ctype || 'application/octet-stream')}`} 93 | ${isDir ? '' : `${xmlEscape(etag || '')}`} 94 | ${extraProps} 95 | 96 | HTTP/1.1 200 OK 97 | 98 | `; 99 | } 100 | let xml = `\n`; 101 | const me = getEntry(store, vpath) || { type: 'dir', name: vpath === '/' ? '/' : vpath.split('/').filter(Boolean).pop(), mtime: new Date().toISOString() }; 102 | const hrefSelf = davHref(vpath, me.type === 'dir'); 103 | const etagSelf = me.type === 'dir' ? '' : makeETag(me); 104 | const ctypeSelf = me.type === 'dir' ? '' : contentTypeByName(me.name || ''); 105 | const extraSelf = me.type === 'dir' ? '' : ` 106 | ${xmlEscape(me.manifest ? normalizeManifest(me.manifest) : '')} 107 | ${xmlEscape(me.singleUrl || '')}`; 108 | xml += entry(hrefSelf, me.name || '/', me.type === 'dir', me.type === 'dir' ? 0 : (me.size || 0), new Date(me.mtime).toUTCString(), etagSelf, ctypeSelf, extraSelf); 109 | if (me.type === 'dir' && depth !== 0) { 110 | const children = listChildren(store, vpath); 111 | for (const c of children) { 112 | const isDir = c.entry.type === 'dir'; 113 | const childHref = davHref(c.path, isDir); 114 | const etag = isDir ? '' : makeETag(c.entry); 115 | const ctype = isDir ? '' : contentTypeByName(c.entry.name || ''); 116 | const extra = isDir ? '' : ` 117 | ${xmlEscape(c.entry.manifest ? normalizeManifest(c.entry.manifest) : '')} 118 | ${xmlEscape(c.entry.singleUrl || '')}`; 119 | xml += entry(childHref, c.entry.name, isDir, isDir ? 0 : (c.entry.size || 0), new Date(c.entry.mtime).toUTCString(), etag, ctype, extra); 120 | } 121 | } 122 | xml += '\n'; 123 | return xml; 124 | } 125 | 126 | async function fetchContentLength(u) { 127 | try { 128 | const r = await fetch(u, { method: 'HEAD' }); 129 | const cl = r.headers.get('content-length'); 130 | return cl ? Number(cl) : 0; 131 | } catch { return 0; } 132 | } 133 | 134 | function extOf(name){ const i = (name || '').lastIndexOf('.'); return i >= 0 ? (name || '').slice(i+1).toLowerCase() : ''; } 135 | 136 | async function computeSizeFromManifest(manifest, filename) { 137 | try { 138 | const ids = manifest.replace(/^\[[^\]]+\]/, '').split(',').filter(Boolean); 139 | const base = getDownloadBase(); 140 | const ext = extOf(filename || ''); 141 | let sum = 0; 142 | for (let id of ids) { 143 | // 单链规则:.chunk--1 替换为真实扩展名 144 | if (/\.chunk--1$/i.test(id)) { id = id.replace(/\.chunk--1$/i, ext ? `.${ext}` : ''); } 145 | sum += await fetchContentLength(base + id); 146 | } 147 | return sum; 148 | } catch { return 0; } 149 | } 150 | 151 | async function readBody(req) { 152 | const chunks = []; 153 | for await (const chunk of req) chunks.push(chunk); 154 | const buf = Buffer.concat(chunks); 155 | return new Uint8Array(buf); 156 | } 157 | 158 | // 高性能、低内存的流式传输:遵循下游写入背压(drain) 159 | async function streamWebToRes(webBody, res) { 160 | for await (const chunk of webBody) { 161 | const ok = res.write(chunk); 162 | if (!ok) { 163 | await new Promise(resolve => res.once('drain', resolve)); 164 | } 165 | } 166 | } 167 | 168 | async function handlePut(store, vpath, req, res) { 169 | // Ensure parent dir exists in metadata 170 | const parent = utils.getParent(vpath); 171 | if (!getEntry(store, parent)) makeDir(store, parent); 172 | 173 | const filename = vpath.split('/').filter(Boolean).pop() || 'file'; 174 | let fileInfo = { name: filename, size: 0 }; 175 | try { 176 | const ctype = (req.headers['content-type'] || '').toLowerCase(); 177 | // Support JSON manifest with optional chunkUrls/chunkLengths provided by client (fast-path, no upstream HEAD) 178 | if (ctype.includes('application/json')) { 179 | const collected = await readBody(req); 180 | let payload = {}; 181 | try { payload = JSON.parse(new TextDecoder().decode(collected)); } catch {} 182 | const manifest = typeof payload.manifest === 'string' ? payload.manifest.trim() : ''; 183 | const singleUrl = typeof payload.singleUrl === 'string' ? payload.singleUrl.trim() : ''; 184 | const chunkUrls = Array.isArray(payload.chunkUrls) ? payload.chunkUrls.filter(Boolean) : undefined; 185 | const chunkLengths = Array.isArray(payload.chunkLengths) ? payload.chunkLengths.map(n => Number(n)).filter(n => Number.isFinite(n) && n >= 0) : undefined; 186 | const sizeIn = Number(payload.size || 0); 187 | const rec = { name: filename }; 188 | if (manifest) Object.assign(rec, { manifest }); 189 | if (singleUrl) Object.assign(rec, { singleUrl }); 190 | if (chunkUrls && chunkUrls.length) Object.assign(rec, { chunkUrls }); 191 | if (chunkLengths && chunkLengths.length) Object.assign(rec, { chunkLengths }); 192 | let totalSize = sizeIn; 193 | if (!totalSize) { 194 | if (chunkLengths && chunkLengths.length) totalSize = chunkLengths.reduce((a,b)=>a+b,0); 195 | } 196 | Object.assign(rec, { size: totalSize || 0 }); 197 | setFile(store, vpath, rec); 198 | saveStore(store); 199 | send(res, 201, { 'Content-Type': 'application/json' }, JSON.stringify({ ok: true, name: filename, kind: (singleUrl ? 'single' : (manifest ? 'manifest' : 'unknown')) })); 200 | return; 201 | } 202 | // Handle plain-text manifest 203 | if (ctype.includes('text/plain')) { 204 | const collected = await readBody(req); 205 | const asText = new TextDecoder().decode(collected); 206 | const isManifest = /^\[[^\]]+\][A-Za-z0-9_,.-]+$/.test(asText.trim()); 207 | if (isManifest) { 208 | fileInfo = { ...fileInfo, manifest: asText.trim(), size: 0 }; 209 | // 尝试计算清单的总大小(支持单链 .chunk--1 通过文件扩展名恢复) 210 | const sz = await computeSizeFromManifest(fileInfo.manifest, fileInfo.name); 211 | if (sz > 0) fileInfo.size = sz; 212 | setFile(store, vpath, fileInfo); 213 | saveStore(store); 214 | const payload = { name: filename, size: fileInfo.size || 0, manifest: fileInfo.manifest, kind: 'manifest' }; 215 | send(res, 201, { 'Content-Type': 'application/json' }, JSON.stringify(payload)); 216 | return; 217 | } 218 | // fallthrough to upload as binary 219 | // Reinject the collected buffer into a simple stream process 220 | let totalSize = 0; 221 | const contentLen = Number(req.headers['content-length'] || 0); 222 | const CHUNK_SIZE = contentLen > 0 ? computeChunkSizeBytes(contentLen) : (MAX_CHUNK_MB * 1024 * 1024); 223 | let buffer = new Uint8Array(CHUNK_SIZE); 224 | let bufferPos = 0; 225 | let flushed = 0; 226 | const chunkUrls = []; 227 | const chunkLengths = []; 228 | const handleFlush = async () => { 229 | if (bufferPos === 0) return; 230 | const part = buffer.subarray(0, bufferPos); 231 | const url = await uploadChunk(part, flushed, filename, 120_000); 232 | chunkUrls.push(url); 233 | chunkLengths.push(part.length); 234 | flushed++; 235 | buffer = new Uint8Array(CHUNK_SIZE); 236 | bufferPos = 0; 237 | }; 238 | // write collected 239 | totalSize += collected.length; 240 | let offset = 0; 241 | while (offset < collected.length) { 242 | const can = Math.min(CHUNK_SIZE - bufferPos, collected.length - offset); 243 | buffer.set(collected.subarray(offset, offset + can), bufferPos); 244 | bufferPos += can; offset += can; 245 | if (bufferPos === CHUNK_SIZE) await handleFlush(); 246 | } 247 | for await (const chunk of req) { 248 | const u8 = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk); 249 | totalSize += u8.length; 250 | let off = 0; 251 | while (off < u8.length) { 252 | const can = Math.min(CHUNK_SIZE - bufferPos, u8.length - off); 253 | buffer.set(u8.subarray(off, off + can), bufferPos); 254 | bufferPos += can; off += can; 255 | if (bufferPos === CHUNK_SIZE) await handleFlush(); 256 | } 257 | } 258 | // finalize 259 | if (flushed === 0 && bufferPos <= (1 * 1024 * 1024)) { 260 | const singleBuf = bufferPos > 0 ? buffer.subarray(0, bufferPos) : new Uint8Array(); 261 | const url = await uploadSingle(singleBuf, filename, 60_000); 262 | const id = url.split('?')[0].split('/').pop() || ''; 263 | const idBase = id.replace(/\.[^./]+$/, ''); 264 | const manifestSingle = `[${encodeURIComponent(filename)}]${idBase}.chunk--1`; 265 | // 统一格式:记录 manifest,同时保留 singleUrl 以便直接转发 266 | fileInfo = { ...fileInfo, singleUrl: url, manifest: manifestSingle, size: totalSize }; 267 | } else { 268 | if (bufferPos > 0) await handleFlush(); 269 | if (chunkUrls.length === 1) { 270 | // 单片结果按单链接处理,避免历史出现“chunk-0”这类占位名 271 | fileInfo = { ...fileInfo, singleUrl: chunkUrls[0], size: totalSize }; 272 | } else { 273 | const manifest = buildChunkManifest(filename, chunkUrls); 274 | fileInfo = { ...fileInfo, manifest, chunkUrls, chunkLengths, size: totalSize }; 275 | } 276 | } 277 | } else { 278 | // Binary streaming 279 | let totalSize = 0; 280 | const contentLen2 = Number(req.headers['content-length'] || 0); 281 | const CHUNK_SIZE = contentLen2 > 0 ? computeChunkSizeBytes(contentLen2) : (MAX_CHUNK_MB * 1024 * 1024); 282 | let buffer = new Uint8Array(CHUNK_SIZE); 283 | let bufferPos = 0; 284 | let flushed = 0; 285 | const chunkUrls = []; 286 | const chunkLengths = []; 287 | const handleFlush = async () => { 288 | if (bufferPos === 0) return; 289 | const part = buffer.subarray(0, bufferPos); 290 | const url = await uploadChunk(part, flushed, filename, 120_000); 291 | chunkUrls.push(url); 292 | chunkLengths.push(part.length); 293 | flushed++; 294 | buffer = new Uint8Array(CHUNK_SIZE); 295 | bufferPos = 0; 296 | }; 297 | for await (const chunk of req) { 298 | const u8 = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk); 299 | totalSize += u8.length; 300 | let off = 0; 301 | while (off < u8.length) { 302 | const can = Math.min(CHUNK_SIZE - bufferPos, u8.length - off); 303 | buffer.set(u8.subarray(off, off + can), bufferPos); 304 | bufferPos += can; off += can; 305 | if (bufferPos === CHUNK_SIZE) await handleFlush(); 306 | } 307 | } 308 | if (flushed === 0 && bufferPos <= (1 * 1024 * 1024)) { 309 | const singleBuf = bufferPos > 0 ? buffer.subarray(0, bufferPos) : new Uint8Array(); 310 | const url = await uploadSingle(singleBuf, filename, 60_000); 311 | const id = url.split('?')[0].split('/').pop() || ''; 312 | const idBase = id.replace(/\.[^./]+$/, ''); 313 | const manifestSingle = `[${encodeURIComponent(filename)}]${idBase}.chunk--1`; 314 | fileInfo = { ...fileInfo, singleUrl: url, manifest: manifestSingle, size: totalSize }; 315 | } else { 316 | if (bufferPos > 0) await handleFlush(); 317 | if (chunkUrls.length === 1) { 318 | fileInfo = { ...fileInfo, singleUrl: chunkUrls[0], size: totalSize }; 319 | } else { 320 | const manifest = buildChunkManifest(filename, chunkUrls); 321 | fileInfo = { ...fileInfo, manifest, chunkUrls, chunkLengths, size: totalSize }; 322 | } 323 | } 324 | } 325 | setFile(store, vpath, fileInfo); 326 | saveStore(store); 327 | const payload = { name: filename, size: fileInfo.size || 0, manifest: fileInfo.manifest, singleUrl: fileInfo.singleUrl, kind: (fileInfo.singleUrl ? 'single' : 'manifest') }; 328 | send(res, 201, { 'Content-Type': 'application/json' }, JSON.stringify(payload)); 329 | } catch { 330 | send(res, 500, {}, 'Provider Upload Error'); 331 | } 332 | } 333 | 334 | async function handleGet(store, vpath, req, res) { 335 | const entry = getEntry(store, vpath); 336 | if (!entry) { send(res, 404, {}, 'Not Found'); return; } 337 | if (entry.type === 'dir') { send(res, 403, {}, 'Is a collection'); return; } 338 | const ctype = contentTypeByName(entry.name || ''); 339 | const etag = makeETag(entry); 340 | const range = (req.headers['range'] || '').toString(); 341 | const isRange = range.startsWith('bytes='); 342 | // 仅当既不是单链、也不是可解析的清单、也没有可用的 chunk 长度信息时才拒绝 Range 343 | if (isRange) { 344 | const hasSingle = !!entry.singleUrl; 345 | const hasManifest = !!entry.manifest; 346 | const hasChunkLens = !!(entry.chunkUrls && entry.chunkLengths); 347 | if (!hasSingle && !hasManifest && !hasChunkLens) { 348 | send(res, 416, {}, 'Range Not Supported'); 349 | return; 350 | } 351 | } 352 | // 小工具:HEAD 预检上游可用性与长度 353 | async function headOne(url){ 354 | try { 355 | const r = await fetch(url, { method: 'HEAD' }); 356 | const lenHeader = r.headers.get('content-length'); 357 | return { ok: r.ok, status: r.status, length: lenHeader ? Number(lenHeader) : undefined }; 358 | } catch { 359 | return { ok: false, status: 0, length: undefined }; 360 | } 361 | } 362 | async function preflightUrls(urls){ 363 | let okAll = true; let totalLen = 0; const details = []; 364 | for (const u of urls){ 365 | const h = await headOne(u); 366 | details.push({ url: u, ...h }); 367 | if (!h.ok) okAll = false; 368 | if (h.length) totalLen += h.length; 369 | } 370 | return { okAll, totalLen: totalLen || undefined, details }; 371 | } 372 | try { 373 | if (entry.singleUrl) { 374 | // 非 Range:高性能低内存,直接按背压流式转发(不设置 Content-Length 使用分块编码) 375 | if (!isRange) { 376 | const r0 = await fetch(entry.singleUrl); 377 | if (!r0.ok || !r0.body) { 378 | const code = r0 && r0.status === 404 ? 404 : 502; 379 | send(res, code, { 'Content-Type': 'text/plain; charset=utf-8' }, code === 404 ? '文件不存在或已失效' : '上游服务不可用'); 380 | return; 381 | } 382 | res.writeHead(200, { 'Content-Type': ctype, 'ETag': etag, 'Content-Disposition': `inline; filename="${encodeURIComponent(entry.name || 'file')}"` }); 383 | await streamWebToRes(r0.body, res); 384 | res.end(); 385 | return; 386 | } 387 | // Range:保持流式但检查失败并返回错误(单链直接按 singleUrl 请求) 388 | const r = await fetch(entry.singleUrl, { headers: { Range: range } }); 389 | if (!r.ok || !r.body) { send(res, r.status === 404 ? 404 : 502, { 'Content-Type': 'text/plain; charset=utf-8' }, '上游服务不可用'); return; } 390 | const cr = r.headers.get('content-range') || ''; 391 | const cl = r.headers.get('content-length') || undefined; 392 | res.writeHead(206, { 'Content-Type': ctype, ...(cl ? { 'Content-Length': cl } : {}), ...(cr ? { 'Content-Range': cr } : {}), 'ETag': etag, 'Content-Disposition': `inline; filename="${encodeURIComponent(entry.name || 'file')}"` }); 393 | await streamWebToRes(r.body, res); 394 | res.end(); 395 | } else if (entry.chunkUrls && entry.chunkUrls.length) { 396 | // Non-range:高性能低内存,顺序流式拼接所有分片 397 | if (!isRange) { 398 | const urls = entry.chunkUrls.slice(); 399 | const totalLen = entry.size || (entry.chunkLengths ? entry.chunkLengths.reduce((a,b)=>a+b,0) : undefined); 400 | res.writeHead(200, { 'Content-Type': ctype, ...(totalLen ? { 'Content-Length': totalLen } : {}), 'Accept-Ranges': 'bytes', 'ETag': etag, 'Content-Disposition': `inline; filename="${encodeURIComponent(entry.name || 'file')}"` }); 401 | for (const url of urls) { 402 | const r = await fetch(url); 403 | if (!r.ok || !r.body) { try { res.end(); } catch { /* noop */ } return; } 404 | await streamWebToRes(r.body, res); 405 | } 406 | res.end(); 407 | return; 408 | } 409 | if (isRange && entry.chunkLengths && entry.chunkLengths.length === entry.chunkUrls.length) { 410 | // Parse range: bytes=start-end 411 | const m = range.match(/^bytes=(\d+)-(\d+)?$/); 412 | if (!m) { send(res, 416, {}, 'Bad Range'); return; } 413 | const start = Number(m[1]); 414 | const end = m[2] ? Number(m[2]) : (entry.size ? entry.size - 1 : undefined); 415 | const totalLen = entry.size || entry.chunkLengths.reduce((a,b)=>a+b,0); 416 | const to = (end !== undefined) ? end : (totalLen - 1); 417 | if (isNaN(start) || isNaN(to) || start > to) { send(res, 416, {}, 'Bad Range'); return; } 418 | // Find chunk indices 419 | let acc = 0; const spans = []; 420 | for (let i=0;i= chunkStartGlobal && start <= chunkEndGlobal){ 426 | const localStart = Math.max(0, start - chunkStartGlobal); 427 | const localEnd = Math.min(len - 1, to - chunkStartGlobal); 428 | spans.push({ i, localStart, localEnd }); 429 | } 430 | acc += len; 431 | } 432 | // Preflight: check all target spans' chunks are available 433 | const preSpans = await preflightUrls(spans.map(s => entry.chunkUrls[s.i])); 434 | if (!preSpans.okAll) { 435 | const notOk = preSpans.details.find(d => !d.ok); 436 | const code = notOk && notOk.status === 404 ? 404 : 502; 437 | send(res, code, { 'Content-Type': 'text/plain' }, 'Upstream chunk missing'); 438 | return; 439 | } 440 | const contentLength = (to - start + 1); 441 | res.writeHead(206, { 'Content-Type': ctype, 'Content-Length': contentLength, 'Content-Range': `bytes ${start}-${to}/${totalLen}`, 'ETag': etag, 'Content-Disposition': `inline; filename="${encodeURIComponent(entry.name || 'file')}"` }); 442 | for (const s of spans){ 443 | const url = entry.chunkUrls[s.i]; 444 | const r = await fetch(url, { headers: { Range: `bytes=${s.localStart}-${s.localEnd}` } }); 445 | if (!r.ok || !r.body) { try { res.end(); } catch { /* noop */ } return; } 446 | await streamWebToRes(r.body, res); 447 | } 448 | res.end(); 449 | return; 450 | } 451 | const urls = entry.chunkUrls.slice(); 452 | const concurrency = 4; 453 | const results = Array.from({ length: urls.length }, () => null); 454 | let next = 0; let started = 0; 455 | async function startOne(i){ 456 | const r = await fetch(urls[i]); 457 | if (!r.ok || !r.body) throw new Error('Upstream Error'); 458 | const bufs = []; 459 | for await (const chunk of r.body) bufs.push(chunk); 460 | results[i] = Buffer.concat(bufs); 461 | while (results[next]) { res.write(results[next]); next++; } 462 | } 463 | while (started < urls.length && started < concurrency) { startOne(started++); } 464 | while (started < urls.length) { await startOne(started++); } 465 | // drain remaining 466 | while (results[next]) { res.write(results[next]); next++; } 467 | res.end(); 468 | } else if (entry.manifest) { 469 | // Non-range:高性能低内存,按清单顺序流式拼接 470 | const ids = normalizeManifest(entry.manifest).replace(/^\[[^\]]+\]/, '').split(',').filter(Boolean); 471 | const base = getDownloadBase(); 472 | const urls = ids.map(id => base + id); 473 | if (!isRange) { 474 | const totalLen = entry.size || undefined; // size may have been computed at upload time or via HEAD 475 | res.writeHead(200, { 'Content-Type': ctype, ...(totalLen ? { 'Content-Length': totalLen } : {}), 'Accept-Ranges': 'bytes', 'ETag': etag, 'Content-Disposition': `inline; filename="${encodeURIComponent(entry.name || 'file')}"` }); 476 | for (const url of urls) { 477 | const r = await fetch(url); 478 | if (!r.ok || !r.body) { try { res.end(); } catch { /* noop */ } return; } 479 | await streamWebToRes(r.body, res); 480 | } 481 | res.end(); 482 | return; 483 | } else { 484 | // Range support for manifest-only entries: map global range across ids 485 | // Preflight HEAD to get lengths 486 | const pre = await preflightUrls(urls); 487 | if (!pre.okAll) { 488 | const notOk = pre.details.find(d => !d.ok); 489 | const code = notOk && notOk.status === 404 ? 404 : 502; 490 | send(res, code, { 'Content-Type': 'text/plain' }, 'Upstream chunk missing'); 491 | return; 492 | } 493 | const lengths = pre.details.map(d => d.length || 0); 494 | const totalLen = pre.totalLen || lengths.reduce((a,b)=>a+b,0); 495 | // Parse range: bytes=start-end 496 | const m = range.match(/^bytes=(\d+)-(\d+)?$/); 497 | if (!m || !totalLen) { send(res, 416, {}, 'Bad Range'); return; } 498 | const start = Number(m[1]); 499 | const end = m[2] ? Number(m[2]) : (totalLen - 1); 500 | const to = end; 501 | if (isNaN(start) || isNaN(to) || start > to) { send(res, 416, {}, 'Bad Range'); return; } 502 | // Compute spans over ids 503 | let acc = 0; const spans = []; 504 | for (let i=0;i= chunkStartGlobal && start <= chunkEndGlobal){ 509 | const localStart = Math.max(0, start - chunkStartGlobal); 510 | const localEnd = Math.min(len - 1, to - chunkStartGlobal); 511 | spans.push({ i, localStart, localEnd }); 512 | } 513 | acc += len; 514 | } 515 | const contentLength = (to - start + 1); 516 | res.writeHead(206, { 'Content-Type': ctype, 'Content-Length': contentLength, 'Content-Range': `bytes ${start}-${to}/${totalLen}`, 'ETag': etag, 'Content-Disposition': `inline; filename="${encodeURIComponent(entry.name || 'file')}"` }); 517 | for (const s of spans){ 518 | const url = urls[s.i]; 519 | const r = await fetch(url, { headers: { Range: `bytes=${s.localStart}-${s.localEnd}` } }); 520 | if (!r.ok || !r.body) { try { res.end(); } catch { /* noop */ } return; } 521 | await streamWebToRes(r.body, res); 522 | } 523 | res.end(); 524 | return; 525 | } 526 | } else { 527 | send(res, 404, {}, 'No Data'); 528 | } 529 | } catch { 530 | if (res.headersSent) { 531 | try { res.end(); } catch { /* noop */ } 532 | } else { 533 | send(res, 500, { 'Content-Type': 'text/plain' }, 'Proxy Error'); 534 | } 535 | } 536 | } 537 | 538 | async function handleHead(store, vpath, res) { 539 | const entry = getEntry(store, vpath); 540 | if (!entry) { send(res, 404, {}, 'Not Found'); return; } 541 | if (entry.type === 'dir') { send(res, 403, {}, 'Is a collection'); return; } 542 | const ctype = contentTypeByName(entry.name || ''); 543 | const etag = makeETag(entry); 544 | let total = entry.size || undefined; 545 | // 如果未记录大小,尝试通过上游 HEAD 预估(单链接或清单) 546 | if (!total) { 547 | try { 548 | if (entry.singleUrl) { 549 | try { 550 | const r = await fetch(entry.singleUrl, { method: 'HEAD' }); 551 | const cl = r.headers.get('content-length'); 552 | if (cl) total = Number(cl); 553 | } catch { /* ignore */ } 554 | if (!total) { 555 | try { 556 | const r2 = await fetch(entry.singleUrl, { headers: { Range: 'bytes=0-0' } }); 557 | const cr = r2.headers.get('content-range'); 558 | const m = cr && cr.match(/\/(\d+)$/); 559 | if (m && m[1]) total = Number(m[1]); 560 | } catch { /* ignore */ } 561 | } 562 | } else if (entry.manifest) { 563 | const ids = normalizeManifest(entry.manifest).replace(/^\[[^\]]+\]/, '').split(',').filter(Boolean); 564 | const base = getDownloadBase(); 565 | let sum = 0; 566 | for (const id of ids) { 567 | let cl = 0; 568 | try { 569 | const rh = await fetch(base + id, { method: 'HEAD' }); 570 | const h = rh.headers.get('content-length'); 571 | if (h) cl = Number(h); 572 | } catch { /* ignore */ } 573 | if (!cl) { 574 | try { 575 | const r2 = await fetch(base + id, { headers: { Range: 'bytes=0-0' } }); 576 | const cr = r2.headers.get('content-range'); 577 | const m = cr && cr.match(/\/(\d+)$/); 578 | if (m && m[1]) cl = Number(m[1]); 579 | } catch { /* ignore */ } 580 | } 581 | if (cl) sum += cl; 582 | } 583 | if (sum > 0) total = sum; 584 | } 585 | } catch { /* ignore */ } 586 | } 587 | send(res, 200, { 'Content-Type': ctype, ...(total ? { 'Content-Length': total } : {}), 'ETag': etag }); 588 | } 589 | 590 | function start() { 591 | const server = http.createServer(async (req, res) => { 592 | if (!rateLimit(req, res)) return; 593 | if (!checkAuth(req, res)) return; 594 | const method = req.method || 'GET'; 595 | const depth = req.headers['depth'] ?? '0'; 596 | const urlPath = req.url || '/'; 597 | const vpath = pathFromUrl(urlPath); 598 | if (vpath == null) { send(res, 404, {}, 'Not Found'); return; } 599 | 600 | const store = loadStore(); 601 | try { 602 | if (method === 'OPTIONS') { handleOptions(req, res); return; } 603 | if (method === 'PROPFIND') { 604 | const xml = propfind(store, vpath, depth === 'infinity' ? 1 : Number(depth)); 605 | send(res, 207, { 'Content-Type': 'application/xml; charset=utf-8' }, xml); 606 | return; 607 | } 608 | if (method === 'MKCOL') { 609 | makeDir(store, vpath); saveStore(store); send(res, 201); return; 610 | } 611 | if (method === 'PUT') { await handlePut(store, vpath, req, res); return; } 612 | if (method === 'GET') { await handleGet(store, vpath, req, res); return; } 613 | if (method === 'HEAD') { await handleHead(store, vpath, res); return; } 614 | // Protect root and myupload from deletion 615 | if (method === 'DELETE' && (vpath === '/' || vpath === '/myupload/')) { send(res, 403, {}, 'Protected'); return; } 616 | if (method === 'DELETE') { 617 | const entry = getEntry(store, vpath); 618 | if (!entry) { send(res, 404, {}, 'Not Found'); return; } 619 | const link = entry.singleUrl || entry.manifest || ''; 620 | const kind = entry.singleUrl ? 'single' : (entry.manifest ? 'manifest' : 'unknown'); 621 | removeEntry(store, vpath); saveStore(store); 622 | if (link) { 623 | const payload = { name: entry.name || '', link, kind }; 624 | send(res, 200, { 'Content-Type': 'application/json' }, JSON.stringify(payload)); 625 | } else { 626 | send(res, 204); 627 | } 628 | return; 629 | } 630 | if (method === 'MOVE') { 631 | const dest = req.headers['destination']; 632 | if (!dest) { send(res, 400, {}, 'Missing Destination'); return; } 633 | const urlObj = new URL(dest, 'http://localhost'); 634 | const np = pathFromUrl(urlObj.pathname); 635 | if (np == null) { send(res, 400, {}, 'Bad Destination'); return; } 636 | const ok = moveEntry(store, vpath, np); 637 | if (ok) { saveStore(store); send(res, 201); } else { send(res, 404, {}, 'Not Found'); } 638 | return; 639 | } 640 | 641 | send(res, 405, {}, 'Method Not Allowed'); 642 | } catch { 643 | send(res, 500, {}, 'Server Error'); 644 | } 645 | }); 646 | 647 | server.listen(PORT, () => { 648 | 649 | console.log(`WebDAV Gateway listening on http://localhost:${PORT}${BASE_PATH}`); 650 | }); 651 | } 652 | 653 | start(); 654 | --------------------------------------------------------------------------------