mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-28 07:32:52 +00:00
273 lines
10 KiB
JavaScript
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}`)
|
|
})
|