Files
hanzo.ai/scripts/drive-server.mjs

273 lines
10 KiB
JavaScript

import { createServer } from 'node:http'
import { existsSync, readdirSync, statSync, mkdirSync, rmSync, createReadStream, createWriteStream } from 'node:fs'
import { join, extname, basename } from 'node:path'
import { pipeline } from 'node:stream/promises'
const PORT = Number(process.env.HANZO_S3_PORT || 9005)
const ROOT = '/Users/a/drive-move'
const SPACES = {
'my-drive': { name: 'my-drive', title: 'My Drive', folder: 'My Drive', shared: false },
'hanzo-ai': { name: 'hanzo-ai', title: 'Hanzo AI', folder: 'Hanzo AI', shared: true },
'hanzo-core': { name: 'hanzo-core', title: 'Hanzo Core', folder: 'Hanzo Core', shared: true },
'hanzo-legal': { name: 'hanzo-legal', title: 'Hanzo Legal', folder: 'Hanzo Legal', shared: true },
'hanzo': { name: 'hanzo', title: 'Hanzo AI', folder: 'Hanzo AI', shared: false },
}
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.pdf': 'application/pdf',
'.mp4': 'video/mp4',
'.mov': 'video/quicktime',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.doc': 'application/msword',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xls': 'application/vnd.ms-excel',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.ppt': 'application/vnd.ms-powerpoint',
'.zip': 'application/zip',
'.tar': 'application/x-tar',
'.gz': 'application/gzip',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.csv': 'text/csv; charset=utf-8',
'.ts': 'text/plain; charset=utf-8',
'.tsx': 'text/plain; charset=utf-8',
}
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD',
'Access-Control-Allow-Headers': '*',
'Access-Control-Expose-Headers': '*',
}
function resolveSpace(bucket) {
if (!bucket) return SPACES['my-drive']
const key = bucket.toLowerCase().trim()
if (SPACES[key]) return SPACES[key]
for (const s of Object.values(SPACES)) {
if (s.title.toLowerCase() === key || s.folder.toLowerCase() === key) return s
}
return SPACES['my-drive']
}
function listBucketEntries(space, prefix = '') {
const normPrefix = prefix.replace(/^\/+|\/+$/g, '')
const blobsDir = normPrefix ? join(ROOT, space.folder, 'blobs', normPrefix) : join(ROOT, space.folder, 'blobs')
const nativeDir = normPrefix ? join(ROOT, space.folder, 'native', normPrefix) : join(ROOT, space.folder, 'native')
const map = new Map()
const scan = (dir) => {
if (!existsSync(dir)) return
try {
const items = readdirSync(dir, { withFileTypes: true })
for (const item of items) {
if (item.name.startsWith('.')) continue
const full = join(dir, item.name)
try {
const st = statSync(full)
const isDir = item.isDirectory()
const key = isDir ? `${item.name}/` : item.name
if (!map.has(key)) {
const ext = extname(item.name).toLowerCase()
map.set(key, {
key,
isDir,
size: isDir ? 0 : st.size,
lastModified: Math.floor(st.mtimeMs),
type: isDir ? undefined : MIME_TYPES[ext] || 'application/octet-stream',
})
}
} catch {}
}
} catch {}
}
scan(blobsDir)
scan(nativeDir)
const entries = Array.from(map.values())
entries.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
return a.key.localeCompare(b.key)
})
return entries
}
function findFile(space, key) {
const cleanKey = key.replace(/^\/+/, '')
const bPath = join(ROOT, space.folder, 'blobs', cleanKey)
if (existsSync(bPath) && statSync(bPath).isFile()) return bPath
const nPath = join(ROOT, space.folder, 'native', cleanKey)
if (existsSync(nPath) && statSync(nPath).isFile()) return nPath
return null
}
const server = createServer(async (req, res) => {
if (req.method === 'OPTIONS') {
res.writeHead(204, CORS_HEADERS)
return res.end()
}
const rawUrl = req.url || '/'
const [pathname, qs] = rawUrl.split('?')
const params = new URLSearchParams(qs || '')
// Health
if (pathname === '/v1/s3/health' || pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json', ...CORS_HEADERS })
return res.end(JSON.stringify({ status: 'ok', service: 'hanzo-cloud-drive' }))
}
// Buckets
if (pathname === '/v1/s3/buckets' && req.method === 'GET') {
const list = [
{ name: 'my-drive', displayName: 'My Drive', createdAt: Date.now() - 86400000 * 30 },
{ name: 'hanzo-ai', displayName: 'Hanzo AI', createdAt: Date.now() - 86400000 * 60 },
{ name: 'hanzo-core', displayName: 'Hanzo Core', createdAt: Date.now() - 86400000 * 90 },
{ name: 'hanzo-legal', displayName: 'Hanzo Legal', createdAt: Date.now() - 86400000 * 120 },
]
res.writeHead(200, { 'Content-Type': 'application/json', ...CORS_HEADERS })
return res.end(JSON.stringify({ buckets: list, data: list }))
}
// Stats
if (pathname === '/v1/s3/stats') {
res.writeHead(200, { 'Content-Type': 'application/json', ...CORS_HEADERS })
return res.end(JSON.stringify({
user: '[email protected]',
usedBytes: 110976866429,
totalBytes: 1099511627776,
usedGB: 103.36,
totalGB: 1024,
fileCount: 5035,
}))
}
// Match /v1/s3/buckets/:bucket/objects...
const matchObjects = pathname.match(/^\/v1\/s3\/buckets\/([^/]+)\/objects(\/.*)?$/)
if (matchObjects) {
const bucketParam = decodeURIComponent(matchObjects[1])
const extraPath = matchObjects[2] ? decodeURIComponent(matchObjects[2].slice(1)) : ''
const space = resolveSpace(bucketParam)
// 1. List objects: GET .../objects (with optional ?prefix=)
if (req.method === 'GET' && (!extraPath || extraPath === '')) {
const prefix = params.get('prefix') || ''
const entries = listBucketEntries(space, prefix)
res.writeHead(200, { 'Content-Type': 'application/json', ...CORS_HEADERS })
return res.end(JSON.stringify({ objects: entries, data: entries }))
}
// 2. Grant Presigned Upload URL: POST .../objects
if (req.method === 'POST' && (!extraPath || extraPath === '')) {
let body = ''
req.on('data', chunk => body += chunk)
req.on('end', () => {
let key = ''
try {
const parsed = JSON.parse(body)
key = parsed.key || ''
} catch {}
const url = `http://localhost:${PORT}/v1/s3/buckets/${encodeURIComponent(bucketParam)}/objects/${key.split('/').map(encodeURIComponent).join('/')}`
res.writeHead(200, { 'Content-Type': 'application/json', ...CORS_HEADERS })
res.end(JSON.stringify({ url, method: 'PUT' }))
})
return
}
// 3. Grant Presigned Download URL: GET .../objects/:key?grant=1 or default
if (req.method === 'GET' && extraPath) {
if (params.get('grant') === 'true' || req.headers.accept?.includes('application/json')) {
const filePath = findFile(space, extraPath)
if (filePath) {
const dlUrl = `http://localhost:${PORT}/v1/s3/buckets/${encodeURIComponent(bucketParam)}/objects/${extraPath.split('/').map(encodeURIComponent).join('/')}`
res.writeHead(200, { 'Content-Type': 'application/json', ...CORS_HEADERS })
return res.end(JSON.stringify({ url: dlUrl }))
}
}
// Direct download / streaming
const filePath = findFile(space, extraPath)
if (!filePath) {
res.writeHead(404, { 'Content-Type': 'application/json', ...CORS_HEADERS })
return res.end(JSON.stringify({ error: 'Not found' }))
}
const st = statSync(filePath)
const ext = extname(filePath).toLowerCase()
const mime = MIME_TYPES[ext] || 'application/octet-stream'
const fileName = basename(filePath)
res.writeHead(200, {
'Content-Type': mime,
'Content-Length': st.size,
'Content-Disposition': `inline; filename="${encodeURIComponent(fileName)}"`,
...CORS_HEADERS,
})
return pipeline(createReadStream(filePath), res)
}
// 4. Direct Upload: PUT .../objects/:key
if (req.method === 'PUT' && extraPath) {
const cleanKey = extraPath.replace(/^\/+/, '')
const targetPath = join(ROOT, space.folder, 'blobs', cleanKey)
const targetDir = join(targetPath, '..')
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true })
if (cleanKey.endsWith('/')) {
mkdirSync(targetPath, { recursive: true })
res.writeHead(200, { 'Content-Type': 'application/json', ...CORS_HEADERS })
return res.end(JSON.stringify({ ok: true, isDir: true }))
}
const ws = createWriteStream(targetPath)
await pipeline(req, ws)
res.writeHead(200, { 'Content-Type': 'application/json', ...CORS_HEADERS })
return res.end(JSON.stringify({ ok: true, key: cleanKey }))
}
// 5. Delete Object: DELETE .../objects/:key
if (req.method === 'DELETE' && extraPath) {
const cleanKey = extraPath.replace(/^\/+/, '')
const bPath = join(ROOT, space.folder, 'blobs', cleanKey)
const nPath = join(ROOT, space.folder, 'native', cleanKey)
let deleted = false
if (existsSync(bPath)) {
try {
rmSync(bPath, { recursive: true, force: true })
deleted = true
} catch {}
}
if (existsSync(nPath)) {
try {
rmSync(nPath, { recursive: true, force: true })
deleted = true
} catch {}
}
res.writeHead(deleted ? 200 : 404, { 'Content-Type': 'application/json', ...CORS_HEADERS })
return res.end(JSON.stringify({ ok: deleted }))
}
}
res.writeHead(404, { 'Content-Type': 'application/json', ...CORS_HEADERS })
res.end(JSON.stringify({ error: 'Not found' }))
})
server.listen(PORT, '0.0.0.0', () => {
console.log(`Hanzo Cloud Drive server listening on http://localhost:${PORT}`)
})