Spaces:
Paused
Paused
| const TOKEN_PREFIX = 'kvault_'; | |
| const TOKEN_KEY_PREFIX = 'api_token:'; | |
| const VALID_SCOPES = new Set(['upload', 'read', 'delete', 'paste']); | |
| const TOKEN_ID_LENGTH = 12; | |
| const TOKEN_SECRET_LENGTH = 40; | |
| const TOKEN_SALT_LENGTH = 16; | |
| const MASK_PREFIX = '******'; | |
| function timingSafeEqual(a, b) { | |
| if (a.length !== b.length) return false; | |
| let mismatch = 0; | |
| for (let i = 0; i < a.length; i += 1) { | |
| mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); | |
| } | |
| return mismatch === 0; | |
| } | |
| function sanitizeTokenId(rawValue = '') { | |
| const value = String(rawValue || '').trim(); | |
| if (!/^[A-Za-z0-9_-]{6,128}$/.test(value)) return ''; | |
| return value; | |
| } | |
| function randomString(length) { | |
| const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; | |
| const bytes = new Uint8Array(length); | |
| crypto.getRandomValues(bytes); | |
| let output = ''; | |
| for (let i = 0; i < length; i += 1) { | |
| output += chars[bytes[i] % chars.length]; | |
| } | |
| return output; | |
| } | |
| function splitToken(rawToken = '') { | |
| const value = String(rawToken || '').trim(); | |
| const match = /^kvault_([A-Za-z0-9_-]{6,128})_([A-Za-z0-9_-]{16,256})$/.exec(value); | |
| if (!match) return null; | |
| return { | |
| tokenId: match[1], | |
| secret: match[2], | |
| value, | |
| }; | |
| } | |
| function normalizeExpiresAt(rawValue) { | |
| if (rawValue == null || rawValue === '') return null; | |
| if (typeof rawValue === 'number') { | |
| return Number.isFinite(rawValue) ? Math.max(0, Math.floor(rawValue)) : null; | |
| } | |
| const text = String(rawValue).trim(); | |
| if (!text) return null; | |
| const numeric = Number(text); | |
| if (Number.isFinite(numeric)) { | |
| return Math.max(0, Math.floor(numeric)); | |
| } | |
| const parsed = Date.parse(text); | |
| if (Number.isFinite(parsed)) { | |
| return Math.max(0, Math.floor(parsed)); | |
| } | |
| return null; | |
| } | |
| function normalizeScopes(rawScopes = []) { | |
| const list = Array.isArray(rawScopes) ? rawScopes : [rawScopes]; | |
| const normalized = []; | |
| list.forEach((item) => { | |
| const scope = String(item || '').trim().toLowerCase(); | |
| if (!VALID_SCOPES.has(scope)) return; | |
| if (normalized.includes(scope)) return; | |
| normalized.push(scope); | |
| }); | |
| return normalized; | |
| } | |
| async function sha256Hex(input) { | |
| const bytes = new TextEncoder().encode(String(input || '')); | |
| const digest = await crypto.subtle.digest('SHA-256', bytes); | |
| return Array.from(new Uint8Array(digest)) | |
| .map((byte) => byte.toString(16).padStart(2, '0')) | |
| .join(''); | |
| } | |
| async function hashTokenSecret(secret, salt) { | |
| return sha256Hex(`${salt}:${secret}`); | |
| } | |
| function maskTokenSuffix(suffix = '') { | |
| const normalized = String(suffix || ''); | |
| return `${MASK_PREFIX}${normalized}`; | |
| } | |
| function ensureKvBinding(env) { | |
| if (!env?.img_url) { | |
| throw new Error('KV binding img_url is not configured.'); | |
| } | |
| } | |
| function toPublicRecord(record = {}) { | |
| return { | |
| id: record.id, | |
| name: record.name, | |
| scopes: Array.isArray(record.scopes) ? [...record.scopes] : [], | |
| expiresAt: record.expiresAt ?? null, | |
| createdAt: Number(record.createdAt || 0), | |
| lastUsedAt: record.lastUsedAt ?? null, | |
| enabled: Boolean(record.enabled), | |
| tokenPreview: record.tokenPreview || maskTokenSuffix(record.tokenSuffix || ''), | |
| }; | |
| } | |
| async function getRecordById(tokenId, env) { | |
| ensureKvBinding(env); | |
| const id = sanitizeTokenId(tokenId); | |
| if (!id) return null; | |
| const value = await env.img_url.get(`${TOKEN_KEY_PREFIX}${id}`, { type: 'json' }); | |
| if (!value || typeof value !== 'object') return null; | |
| return { | |
| ...value, | |
| id, | |
| scopes: normalizeScopes(value.scopes || []), | |
| enabled: value.enabled !== false, | |
| expiresAt: normalizeExpiresAt(value.expiresAt), | |
| createdAt: Number(value.createdAt || 0), | |
| lastUsedAt: value.lastUsedAt == null ? null : Number(value.lastUsedAt || 0), | |
| }; | |
| } | |
| async function putRecord(record, env) { | |
| ensureKvBinding(env); | |
| await env.img_url.put(`${TOKEN_KEY_PREFIX}${record.id}`, JSON.stringify(record)); | |
| } | |
| async function generateUniqueTokenId(env, maxAttempts = 10) { | |
| for (let i = 0; i < maxAttempts; i += 1) { | |
| const candidate = randomString(TOKEN_ID_LENGTH); | |
| const exists = await env.img_url.get(`${TOKEN_KEY_PREFIX}${candidate}`); | |
| if (exists == null) { | |
| return candidate; | |
| } | |
| } | |
| throw new Error('Failed to generate a unique token id.'); | |
| } | |
| export function getApiTokenScopes() { | |
| return [...VALID_SCOPES]; | |
| } | |
| export function parseBearerToken(request) { | |
| const authorization = String(request.headers.get('Authorization') || '').trim(); | |
| if (!authorization) return ''; | |
| const match = /^Bearer\s+(.+)$/i.exec(authorization); | |
| if (!match) return ''; | |
| return String(match[1] || '').trim(); | |
| } | |
| export async function createApiToken({ name, scopes, expiresAt, enabled = true }, env) { | |
| ensureKvBinding(env); | |
| const normalizedName = String(name || '').trim(); | |
| if (!normalizedName) { | |
| throw new Error('Token name is required.'); | |
| } | |
| const normalizedScopes = normalizeScopes(scopes); | |
| if (normalizedScopes.length === 0) { | |
| throw new Error('At least one valid scope is required.'); | |
| } | |
| const tokenId = await generateUniqueTokenId(env); | |
| const tokenSecret = randomString(TOKEN_SECRET_LENGTH); | |
| const tokenSalt = randomString(TOKEN_SALT_LENGTH); | |
| const tokenHash = await hashTokenSecret(tokenSecret, tokenSalt); | |
| const tokenSuffix = tokenSecret.slice(-6); | |
| const now = Date.now(); | |
| const record = { | |
| id: tokenId, | |
| name: normalizedName, | |
| scopes: normalizedScopes, | |
| expiresAt: normalizeExpiresAt(expiresAt), | |
| createdAt: now, | |
| lastUsedAt: null, | |
| enabled: enabled !== false, | |
| tokenSalt, | |
| tokenHash, | |
| tokenSuffix, | |
| tokenPreview: maskTokenSuffix(tokenSuffix), | |
| }; | |
| await putRecord(record, env); | |
| return { | |
| token: `${TOKEN_PREFIX}${tokenId}_${tokenSecret}`, | |
| record: toPublicRecord(record), | |
| }; | |
| } | |
| export async function listApiTokens(env) { | |
| ensureKvBinding(env); | |
| const keys = []; | |
| let cursor = undefined; | |
| let guard = 0; | |
| do { | |
| const page = await env.img_url.list({ | |
| prefix: TOKEN_KEY_PREFIX, | |
| limit: 1000, | |
| cursor, | |
| }); | |
| keys.push(...(page.keys || []).map((item) => item.name)); | |
| cursor = page.list_complete ? undefined : page.cursor; | |
| guard += 1; | |
| } while (cursor && guard < 10000); | |
| const records = await Promise.all( | |
| keys.map(async (key) => { | |
| const id = key.slice(TOKEN_KEY_PREFIX.length); | |
| return getRecordById(id, env); | |
| }) | |
| ); | |
| return records | |
| .filter(Boolean) | |
| .sort((left, right) => Number(right.createdAt || 0) - Number(left.createdAt || 0)) | |
| .map((record) => toPublicRecord(record)); | |
| } | |
| export async function updateApiToken(tokenId, patch = {}, env) { | |
| const record = await getRecordById(tokenId, env); | |
| if (!record) return null; | |
| const next = { ...record }; | |
| if (Object.prototype.hasOwnProperty.call(patch, 'enabled')) { | |
| next.enabled = Boolean(patch.enabled); | |
| } | |
| if (Object.prototype.hasOwnProperty.call(patch, 'name')) { | |
| const normalizedName = String(patch.name || '').trim(); | |
| if (!normalizedName) { | |
| throw new Error('Token name is required.'); | |
| } | |
| next.name = normalizedName; | |
| } | |
| if (Object.prototype.hasOwnProperty.call(patch, 'scopes')) { | |
| const normalizedScopes = normalizeScopes(patch.scopes); | |
| if (normalizedScopes.length === 0) { | |
| throw new Error('At least one valid scope is required.'); | |
| } | |
| next.scopes = normalizedScopes; | |
| } | |
| if (Object.prototype.hasOwnProperty.call(patch, 'expiresAt')) { | |
| next.expiresAt = normalizeExpiresAt(patch.expiresAt); | |
| } | |
| await putRecord(next, env); | |
| return toPublicRecord(next); | |
| } | |
| export async function deleteApiToken(tokenId, env) { | |
| ensureKvBinding(env); | |
| const id = sanitizeTokenId(tokenId); | |
| if (!id) return false; | |
| const existing = await getRecordById(id, env); | |
| if (!existing) return false; | |
| await env.img_url.delete(`${TOKEN_KEY_PREFIX}${id}`); | |
| return true; | |
| } | |
| export async function touchApiTokenLastUsed(tokenId, env) { | |
| const record = await getRecordById(tokenId, env); | |
| if (!record) return false; | |
| record.lastUsedAt = Date.now(); | |
| await putRecord(record, env); | |
| return true; | |
| } | |
| export async function verifyApiToken(tokenValue, env, requiredScope = '') { | |
| const split = splitToken(tokenValue); | |
| if (!split) { | |
| return { | |
| ok: false, | |
| status: 401, | |
| code: 'TOKEN_INVALID', | |
| message: 'API Token is invalid.', | |
| }; | |
| } | |
| const record = await getRecordById(split.tokenId, env); | |
| if (!record) { | |
| return { | |
| ok: false, | |
| status: 401, | |
| code: 'TOKEN_INVALID', | |
| message: 'API Token is invalid.', | |
| }; | |
| } | |
| const expectedHash = await hashTokenSecret(split.secret, record.tokenSalt || ''); | |
| if (!timingSafeEqual(expectedHash, String(record.tokenHash || ''))) { | |
| return { | |
| ok: false, | |
| status: 401, | |
| code: 'TOKEN_INVALID', | |
| message: 'API Token is invalid.', | |
| }; | |
| } | |
| if (!record.enabled) { | |
| return { | |
| ok: false, | |
| status: 401, | |
| code: 'TOKEN_DISABLED', | |
| message: 'API Token is disabled.', | |
| }; | |
| } | |
| if (Number.isFinite(record.expiresAt) && record.expiresAt > 0 && Date.now() > record.expiresAt) { | |
| return { | |
| ok: false, | |
| status: 401, | |
| code: 'TOKEN_EXPIRED', | |
| message: 'API Token has expired.', | |
| }; | |
| } | |
| const normalizedScope = String(requiredScope || '').trim().toLowerCase(); | |
| if (normalizedScope && !record.scopes.includes(normalizedScope)) { | |
| return { | |
| ok: false, | |
| status: 403, | |
| code: 'TOKEN_SCOPE_DENIED', | |
| message: `API Token does not include "${normalizedScope}" scope.`, | |
| }; | |
| } | |
| return { | |
| ok: true, | |
| token: toPublicRecord(record), | |
| }; | |
| } | |