diff --git a/package.json b/package.json index ea922bd..03d0f8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kevisual/api", - "version": "0.0.28", + "version": "0.0.29", "description": "", "main": "mod.ts", "scripts": { @@ -27,7 +27,9 @@ "@kevisual/types": "^0.0.12", "@kevisual/use-config": "^1.0.28", "@types/bun": "^1.3.6", + "@types/crypto-js": "^4.2.2", "@types/node": "^25.0.10", + "crypto-js": "^4.2.0", "dotenv": "^17.2.3", "fast-glob": "^3.3.3" }, @@ -37,7 +39,8 @@ "es-toolkit": "^1.44.0", "eventemitter3": "^5.0.4", "fuse.js": "^7.1.0", - "nanoid": "^5.1.6" + "nanoid": "^5.1.6", + "path-browserify-esm": "^1.0.6" }, "exports": { ".": "./mod.ts", @@ -46,6 +49,7 @@ "./config": "./query/query-config/query-config.ts", "./proxy": "./query/query-proxy/index.ts", "./secret": "./query/query-secret/index.ts", + "./resources": "./query/query-resources/index.ts", "./query/*": "./query/*" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 172ccf0..2af1bb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: nanoid: specifier: ^5.1.6 version: 5.1.6 + path-browserify-esm: + specifier: ^1.0.6 + version: 1.0.6 devDependencies: '@kevisual/cache': specifier: ^0.0.5 @@ -45,9 +48,15 @@ importers: '@types/bun': specifier: ^1.3.6 version: 1.3.6 + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 '@types/node': specifier: ^25.0.10 version: 25.0.10 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -124,6 +133,9 @@ packages: '@types/bun@1.3.6': resolution: {integrity: sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==} + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/node-fetch@2.6.12': resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} @@ -165,6 +177,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -344,6 +359,9 @@ packages: zod: optional: true + path-browserify-esm@1.0.6: + resolution: {integrity: sha512-9nUwYvvu/yq1PYrUyYCihNWmpzacaRYF6gGbjLWErrZ4MRDWyfPN7RpE8E7tsw8eqBU/rr7mcoTXbS+Vih8uUA==} + path-to-regexp@8.2.0: resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} engines: {node: '>=16'} @@ -455,6 +473,8 @@ snapshots: dependencies: bun-types: 1.3.6 + '@types/crypto-js@4.2.2': {} + '@types/node-fetch@2.6.12': dependencies: '@types/node': 22.15.27 @@ -503,6 +523,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + crypto-js@4.2.0: {} + delayed-stream@1.0.0: {} dotenv@17.2.3: {} @@ -659,6 +681,8 @@ snapshots: transitivePeerDependencies: - encoding + path-browserify-esm@1.0.6: {} + path-to-regexp@8.2.0: {} picomatch@2.3.1: {} diff --git a/query/query-resources/index.ts b/query/query-resources/index.ts index a913183..76307f0 100644 --- a/query/query-resources/index.ts +++ b/query/query-resources/index.ts @@ -1,4 +1,6 @@ import { adapter, DataOpts, Result } from '@kevisual/query'; +import path from 'path-browserify-esm'; +import { hashContent } from './utils'; type QueryResourcesOptions = { prefix?: string; @@ -20,6 +22,9 @@ export class QueryResources { setUsername(username: string) { this.prefix = `/${username}/resources/`; } + setPrefix(prefix: string) { + this.prefix = prefix; + } header(headers?: Record, json = true): Record { const token = this.storage.getItem('token'); const _headers: Record = { @@ -54,18 +59,93 @@ export class QueryResources { }); } async fetchFile(filepath: string, opts?: DataOpts): Promise> { - return fetch(`${this.prefix}${filepath}`, { - method: 'GET', - headers: this.header(opts?.headers, false), - }).then(async (res) => { - if (!res.ok) { - return { - code: 500, - success: false, - message: `Failed to fetch file: ${res.status} ${res.statusText}`, - } as Result; - } - return { code: 200, data: await res.text(), success: true } as Result; + const url = `${this.prefix}${filepath}`; + return this.get({}, { url, method: 'GET', headers: this.header(opts?.headers, false), isText: true }); + } + async uploadFile(filepath: string, content: string, opts?: DataOpts): Promise> { + const pathname = `${this.prefix}${filepath}`; + const filename = path.basename(pathname); + const type = getContentType(filename); + const url = new URL(pathname, window.location.origin); + const hash = hashContent(content); + url.searchParams.set('hash', hash); + const formData = new FormData(); + formData.append('file', new Blob([content], { type })); + return adapter({ + url: url.toString(), + headers: { ...this.header(opts?.headers, false) }, + isPostFile: true, + method: 'POST', + body: formData, }); } } + +export const getContentType = (filename: string): string => { + const ext = path.extname(filename); + let type = 'text/plain'; + + switch (ext) { + case '': + type = 'application/octet-stream'; + break; + case '.json': + type = 'application/json'; + break; + case '.txt': + type = 'text/plain'; + break; + case '.csv': + type = 'text/csv'; + break; + case '.md': + type = 'text/markdown'; + break; + case '.html': + case '.htm': + type = 'text/html'; + break; + case '.xml': + type = 'application/xml'; + break; + case '.js': + type = 'application/javascript'; + break; + case '.css': + type = 'text/css'; + break; + case '.ts': + type = 'application/typescript'; + break; + case '.pdf': + type = 'application/pdf'; + break; + case '.zip': + type = 'application/zip'; + break; + case '.docx': + type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + break; + case '.xlsx': + type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + break; + case '.mp3': + type = 'audio/mpeg'; + break; + case '.mp4': + type = 'video/mp4'; + break; + case '.png': + case '.jpg': + case '.jpeg': + case '.gif': + case '.webp': + type = `image/${ext.slice(1)}`; + break; + case '.svg': + type = 'image/svg+xml'; + break; + } + + return type; +}; diff --git a/query/query-resources/utils.ts b/query/query-resources/utils.ts new file mode 100644 index 0000000..895734f --- /dev/null +++ b/query/query-resources/utils.ts @@ -0,0 +1,42 @@ +import MD5 from 'crypto-js/md5'; + +export const hashContent = (str: string | Buffer): string => { + if (typeof str === 'string') { + return MD5(str).toString(); + } else if (Buffer.isBuffer(str)) { + return MD5(str.toString()).toString(); + } + console.error('hashContent error: input must be a string or Buffer'); + return ''; +}; +export const hashFile = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = async (event) => { + try { + const content = event.target?.result; + if (content instanceof ArrayBuffer) { + const contentString = new TextDecoder().decode(content); + const hashHex = MD5(contentString).toString(); + resolve(hashHex); + } else if (typeof content === 'string') { + const hashHex = MD5(content).toString(); + resolve(hashHex); + } else { + throw new Error('Invalid content type'); + } + } catch (error) { + console.error('hashFile error', error); + reject(error); + } + }; + + reader.onerror = (error) => { + reject(error); + }; + + // 读取文件为 ArrayBuffer + reader.readAsArrayBuffer(file); + }); +}; diff --git a/query/utils/random.ts b/query/utils/random.ts new file mode 100644 index 0000000..3dc9c1e --- /dev/null +++ b/query/utils/random.ts @@ -0,0 +1,26 @@ +import { customAlphabet } from 'nanoid'; + +export const letter = 'abcdefghijklmnopqrstuvwxyz'; +export const number = '0123456789'; +const alphanumeric = `${letter}${number}`; +export const alphanumericWithDash = `${alphanumeric}-`; +export const uuid = customAlphabet(letter); + +export const nanoid = customAlphabet(alphanumeric, 10); + +export const nanoidWithDash = customAlphabet(alphanumericWithDash, 10); + +/** + * 创建一个随机的 id,以字母开头的字符串 + * @param number + * @returns + */ +export const randomId = (number: number) => { + const _letter = uuid(1); + return `${_letter}${nanoid(number)}`; +}; + +export const randomLetter = (number: number = 8, opts?: { before?: string; after?: string }) => { + const { before = '', after = '' } = opts || {}; + return `${before}${uuid(number)}${after}`; +};