/** * R2 Storage Manager v2.3 โ Text Editor + Create File * Owner: @kopikapal1 */ export default { async fetch(request, env, ctx) { const url = new URL(request.url); const path = url.pathname; // Route: Public Folder (NO AUTH) if (path.startsWith('/pub/')) { return handlePublicFolder(request, env, path); } // Route: Shared/Public Links (NO AUTH) if (path.startsWith('/s/')) { return handleSharedLink(request, env, path); } // Route: API Endpoints if (path.startsWith('/api/')) { return handleApiRequest(request, env, path); } // Route: Authentication Pages if (path === '/login') { if (request.method === 'POST') return handleLogin(request, env); return new Response(renderLoginHTML(), { headers: { 'Content-Type': 'text/html' } }); } if (path === '/logout') { return new Response(null, { status: 302, headers: { 'Location': '/login', 'Set-Cookie': 'auth_token=; Max-Age=0; Path=/' } }); } // Route: Main Application (Protected) const authCheck = await verifySession(request, env); if (!authCheck.valid) { return new Response(null, { status: 302, headers: { 'Location': '/login' } }); } if (path === '/' || path === '/app') { return new Response(renderAppHTML(), { headers: { 'Content-Type': 'text/html' } }); } return new Response('Not Found', { status: 404 }); } }; // ============================================ // ๐ฅ PUBLIC FOLDER HANDLER (NO AUTH) // ============================================ async function handlePublicFolder(request, env, path) { const pathParts = path.replace('/pub/', '').split('/'); const token = pathParts[0]; const subPath = '/' + pathParts.slice(1).join('/'); if (!token) { return new Response(renderShareErrorHTML('Link tidak valid.'), { headers: { 'Content-Type': 'text/html' } }); } const shareObj = await env.MAIN_BUCKET.get(`__shares__/${token}`); if (!shareObj) { return new Response(renderShareErrorHTML('Link tidak valid atau sudah kadaluarsa.'), { headers: { 'Content-Type': 'text/html' } }); } try { const shareText = await shareObj.text(); const share = JSON.parse(shareText); if (share.expiresAt && Date.now() > share.expiresAt) { return new Response(renderShareErrorHTML('Link sudah kadaluarsa.'), { headers: { 'Content-Type': 'text/html' } }); } share.views = (share.views || 0) + 1; await env.MAIN_BUCKET.put(`__shares__/${token}`, JSON.stringify(share)); const folderPrefix = share.key.endsWith('/') ? share.key : share.key + '/'; const fullPath = subPath === '/' ? folderPrefix : folderPrefix + subPath.replace(/^\//, ''); const lastPart = pathParts[pathParts.length - 1]; const isFile = lastPart && lastPart.includes('.'); if (isFile) { const fileKey = folderPrefix + pathParts.slice(1).join('/'); const object = await env.MAIN_BUCKET.get(fileKey); if (object) { const headers = new Headers(); object.writeHttpMetadata(headers); const filename = fileKey.split('/').pop(); const ext = filename.split('.').pop().toLowerCase(); if (['mp4', 'webm', 'mp3', 'wav', 'ogg', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'].includes(ext)) { headers.set('Content-Disposition', `inline; filename="${filename}"`); } else { headers.set('Content-Disposition', `attachment; filename="${filename}"`); } let options = {}; const rangeHeader = request.headers.get('Range'); if (rangeHeader) { const match = rangeHeader.match(/bytes=(\d+)-(\d*)/); if (match) { options.range = { offset: parseInt(match[1]), length: match[2] ? parseInt(match[2]) - parseInt(match[1]) + 1 : undefined }; } } const streamObj = await env.MAIN_BUCKET.get(fileKey, options); if (streamObj.range) { headers.set('Content-Range', `bytes ${streamObj.range.offset}-${streamObj.range.offset + streamObj.range.length - 1}/${object.size}`); return new Response(streamObj.body, { status: 206, headers }); } return new Response(streamObj.body, { headers }); } } const list = await env.MAIN_BUCKET.list({ prefix: fullPath, delimiter: '/' }); const files = list.objects .filter(obj => !obj.key.startsWith('__shares__/')) .map(obj => { let name = obj.key.substring(fullPath.length); if (name.startsWith('/')) name = name.substring(1); return { key: obj.key, name: name, size: obj.size, uploaded: obj.uploaded, type: 'file' }; }) .filter(f => f.name && !f.name.includes('/') && f.name !== '.keep'); const folders = (list.delimitedPrefixes || []) .filter(p => !p.startsWith('__shares__/')) .map(p => { let name = p.substring(fullPath.length); if (name.endsWith('/')) name = name.slice(0, -1); if (name.startsWith('/')) name = name.substring(1); return { key: p, name: name, type: 'folder' }; }) .filter(f => f.name && f.name !== '.keep'); const folderName = share.folderName || share.key.split('/').filter(Boolean).pop() || 'Shared Folder'; return new Response(renderPublicFolderHTML(token, folderName, subPath, files, folders, share), { headers: { 'Content-Type': 'text/html' } }); } catch (err) { return new Response(renderShareErrorHTML('Gagal memproses link: ' + err.message), { headers: { 'Content-Type': 'text/html' } }); } } // ============================================ // SHARED LINK HANDLER (NO AUTH) // ============================================ async function handleSharedLink(request, env, path) { const token = path.replace('/s/', '').split('/')[0]; const shareObj = await env.MAIN_BUCKET.get(`__shares__/${token}`); if (!shareObj) { return new Response(renderShareErrorHTML('Link tidak valid atau sudah kadaluarsa.'), { headers: { 'Content-Type': 'text/html' } }); } try { const shareText = await shareObj.text(); const share = JSON.parse(shareText); if (share.expiresAt && Date.now() > share.expiresAt) { return new Response(renderShareErrorHTML('Link sudah kadaluarsa.'), { headers: { 'Content-Type': 'text/html' } }); } if (share.type === 'folder') { return new Response(null, { status: 302, headers: { 'Location': '/pub/' + token + '/' } }); } share.views = (share.views || 0) + 1; await env.MAIN_BUCKET.put(`__shares__/${token}`, JSON.stringify(share)); const object = await env.MAIN_BUCKET.get(share.key); if (!object) { return new Response(renderShareErrorHTML('File tidak ditemukan.'), { headers: { 'Content-Type': 'text/html' } }); } if (request.method === 'GET') { return new Response(renderSharePageHTML(share, object), { headers: { 'Content-Type': 'text/html' } }); } const headers = new Headers(); object.writeHttpMetadata(headers); headers.set('etag', object.httpEtag); const filename = share.key.split('/').pop(); const ext = filename.split('.').pop().toLowerCase(); if (['mp4', 'webm', 'mp3', 'wav', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'].includes(ext)) { headers.set('Content-Disposition', `inline; filename="${filename}"`); } else { headers.set('Content-Disposition', `attachment; filename="${filename}"`); } let options = {}; const rangeHeader = request.headers.get('Range'); if (rangeHeader) { const match = rangeHeader.match(/bytes=(\d+)-(\d*)/); if (match) { options.range = { offset: parseInt(match[1]), length: match[2] ? parseInt(match[2]) - parseInt(match[1]) + 1 : undefined }; } } const streamObject = await env.MAIN_BUCKET.get(share.key, options); if (streamObject.range) { headers.set('Content-Range', `bytes ${streamObject.range.offset}-${streamObject.range.offset + streamObject.range.length - 1}/${object.size}`); return new Response(streamObject.body, { status: 206, headers }); } return new Response(streamObject.body, { headers }); } catch (err) { return new Response(renderShareErrorHTML('Gagal memproses link.'), { headers: { 'Content-Type': 'text/html' } }); } } // ============================================ // API HANDLERS // ============================================ async function handleApiRequest(req, env, path) { const auth = await verifySession(req, env); if (!auth.valid) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); const method = req.method; const url = new URL(req.url); try { switch (true) { case path === '/api/files' && method === 'GET': return await apiListFiles(req, env); case path === '/api/upload' && method === 'POST': return await apiUploadFile(req, env); case path === '/api/save-file' && method === 'POST': return await apiSaveTextFile(req, env); case path === '/api/read-file' && method === 'GET': return await apiReadTextFile(req, env, url); case path === '/api/delete' && method === 'DELETE': return await apiDeleteFile(req, env); case path === '/api/stats' && method === 'GET': return await apiGetStats(env); case path === '/api/download' && method === 'GET': return await apiDownloadFile(req, env, url); case path === '/api/stream' && method === 'GET': return await apiStreamFile(req, env, url); case path === '/api/share/create' && method === 'POST': return await apiCreateShareLink(req, env); case path === '/api/share/list' && method === 'GET': return await apiListShareLinks(env); case path === '/api/share/delete' && method === 'DELETE': return await apiDeleteShareLink(req, env); default: return new Response(JSON.stringify({ error: 'Endpoint not found' }), { status: 404 }); } } catch (err) { return new Response(JSON.stringify({ error: err.message }), { status: 500 }); } } async function apiListFiles(req, env) { const url = new URL(req.url); const prefix = url.searchParams.get('prefix') || ''; const list = await env.MAIN_BUCKET.list({ prefix, delimiter: '/' }); const files = list.objects .filter(obj => !obj.key.startsWith('__shares__/')) .map(obj => ({ key: obj.key, size: obj.size, uploaded: obj.uploaded, type: 'file' })); const folders = (list.delimitedPrefixes || []) .filter(p => !p.startsWith('__shares__/')) .map(p => ({ key: p, type: 'folder' })); return new Response(JSON.stringify({ data: [...folders, ...files] }), { headers: { 'Content-Type': 'application/json' } }); } async function apiUploadFile(req, env) { const formData = await req.formData(); const file = formData.get('file'); let path = formData.get('path') || ''; if (!file) return new Response(JSON.stringify({ error: 'No file' }), { status: 400 }); if (path && !path.endsWith('/')) path += '/'; const key = path ? `${path}${file.name}` : file.name; await env.MAIN_BUCKET.put(key, file.stream(), { httpMetadata: { contentType: file.type || 'application/octet-stream' } }); return new Response(JSON.stringify({ success: true, key })); } // ๐ฅ SAVE TEXT FILE (CREATE/EDIT) async function apiSaveTextFile(req, env) { const body = await req.json(); const { path = '', filename, content, contentType = 'text/plain' } = body; if (!filename) return new Response(JSON.stringify({ error: 'Filename required' }), { status: 400 }); let fullPath = path || ''; if (fullPath && !fullPath.endsWith('/')) fullPath += '/'; const key = fullPath + filename; const blob = new Blob([content || ''], { type: contentType }); await env.MAIN_BUCKET.put(key, blob.stream(), { httpMetadata: { contentType } }); return new Response(JSON.stringify({ success: true, key })); } // ๐ฅ READ TEXT FILE async function apiReadTextFile(req, env, url) { const key = url.searchParams.get('key'); if (!key) return new Response(JSON.stringify({ error: 'No key' }), { status: 400 }); const object = await env.MAIN_BUCKET.get(key); if (!object) return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 }); const text = await object.text(); return new Response(JSON.stringify({ success: true, key, content: text, contentType: object.httpMetadata?.contentType || 'text/plain', size: object.size }), { headers: { 'Content-Type': 'application/json' } }); } async function apiDeleteFile(req, env) { const body = await req.json(); if (!body.key) return new Response(JSON.stringify({ error: 'No key' }), { status: 400 }); if (body.key.endsWith('/')) { const list = await env.MAIN_BUCKET.list({ prefix: body.key }); for (const obj of list.objects) await env.MAIN_BUCKET.delete(obj.key); } else { await env.MAIN_BUCKET.delete(body.key); } const shares = await listAllShares(env); for (const share of shares) { if (share.key === body.key || share.key.startsWith(body.key)) { await env.MAIN_BUCKET.delete(`__shares__/${share.token}`); } } return new Response(JSON.stringify({ success: true })); } async function apiDownloadFile(req, env, url) { const key = url.searchParams.get('key'); if (!key || key.endsWith('/')) return new Response('Invalid key', { status: 400 }); const object = await env.MAIN_BUCKET.get(key); if (!object) return new Response('Not found', { status: 404 }); const headers = new Headers(); object.writeHttpMetadata(headers); headers.set('etag', object.httpEtag); headers.set('Content-Disposition', `attachment; filename="${key.split('/').pop()}"`); return new Response(object.body, { headers }); } async function apiStreamFile(req, env, url) { const key = url.searchParams.get('key'); if (!key) return new Response('Missing key', { status: 400 }); let options = {}; const rangeHeader = req.headers.get('Range'); if (rangeHeader) { const match = rangeHeader.match(/bytes=(\d+)-(\d*)/); if (match) options.range = { offset: parseInt(match[1]), length: match[2] ? parseInt(match[2]) - parseInt(match[1]) + 1 : undefined }; } const object = await env.MAIN_BUCKET.get(key, options); if (!object) return new Response('Not found', { status: 404 }); const headers = new Headers(); object.writeHttpMetadata(headers); headers.set('etag', object.httpEtag); headers.set('Accept-Ranges', 'bytes'); if (object.range) { headers.set('Content-Range', `bytes ${object.range.offset}-${object.range.offset + object.range.length - 1}/${object.size}`); return new Response(object.body, { status: 206, headers }); } return new Response(object.body, { headers }); } async function apiGetStats(env) { const list = await env.MAIN_BUCKET.list(); let totalSize = 0, totalFiles = 0; list.objects.forEach(obj => { if (!obj.key.startsWith('__shares__/')) { totalSize += obj.size; totalFiles++; } }); const shares = await listAllShares(env); return new Response(JSON.stringify({ totalFiles, totalSize, totalShares: shares.length, folderShares: shares.filter(s => s.type === 'folder').length, fileShares: shares.filter(s => s.type === 'file').length }), { headers: { 'Content-Type': 'application/json' } }); } async function generateToken() { const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; let token = ''; const bytes = crypto.getRandomValues(new Uint8Array(12)); for (let i = 0; i < 12; i++) token += chars[bytes[i] % chars.length]; return token; } async function apiCreateShareLink(req, env) { const body = await req.json(); const { key, expiresInHours = 0, type = 'file' } = body; if (!key) return new Response(JSON.stringify({ error: 'No key' }), { status: 400 }); const token = await generateToken(); const now = Date.now(); const shareData = { token, key, type, filename: key.split('/').filter(Boolean).pop() || key, folderName: type === 'folder' ? (key.split('/').filter(Boolean).pop() || 'Root') : null, size: 0, contentType: 'application/octet-stream', createdAt: now, expiresAt: expiresInHours > 0 ? now + (expiresInHours * 3600000) : null, views: 0 }; if (type === 'file') { const object = await env.MAIN_BUCKET.get(key); if (!object) return new Response(JSON.stringify({ error: 'File not found' }), { status: 404 }); shareData.size = object.size; shareData.contentType = object.httpMetadata?.contentType || 'application/octet-stream'; } await env.MAIN_BUCKET.put(`__shares__/${token}`, JSON.stringify(shareData)); const basePath = type === 'folder' ? '/pub/' : '/s/'; const shareUrl = basePath + token + (type === 'folder' ? '/' : ''); return new Response(JSON.stringify({ success: true, shareUrl, token, type, expiresAt: shareData.expiresAt ? new Date(shareData.expiresAt).toISOString() : null }), { headers: { 'Content-Type': 'application/json' } }); } async function listAllShares(env) { const list = await env.MAIN_BUCKET.list({ prefix: '__shares__/' }); const shares = []; for (const obj of list.objects) { try { const data = await env.MAIN_BUCKET.get(obj.key); if (data) shares.push(JSON.parse(await data.text())); } catch (e) {} } return shares.sort((a, b) => b.createdAt - a.createdAt); } async function apiListShareLinks(env) { const shares = await listAllShares(env); const result = shares.map(s => ({ token: s.token, key: s.key, type: s.type || 'file', filename: s.filename, folderName: s.folderName, size: s.size, views: s.views || 0, createdAt: new Date(s.createdAt).toISOString(), expiresAt: s.expiresAt ? new Date(s.expiresAt).toISOString() : null, expired: s.expiresAt ? Date.now() > s.expiresAt : false, shareUrl: (s.type === 'folder' ? '/pub/' : '/s/') + s.token + (s.type === 'folder' ? '/' : '') })); return new Response(JSON.stringify({ data: result }), { headers: { 'Content-Type': 'application/json' } }); } async function apiDeleteShareLink(req, env) { const body = await req.json(); if (!body.token) return new Response(JSON.stringify({ error: 'No token' }), { status: 400 }); await env.MAIN_BUCKET.delete(`__shares__/${body.token}`); return new Response(JSON.stringify({ success: true })); } async function handleLogin(req, env) { const formData = await req.formData(); const user = formData.get('username'), pass = formData.get('password'); if (user === env.ADMIN_USER && pass === env.ADMIN_PASS) { const token = btoa(JSON.stringify({ user, exp: Date.now() + 86400000 })) + ".sig"; return new Response(null, { status: 302, headers: { 'Location': '/', 'Set-Cookie': `auth_token=${token}; HttpOnly; Secure; Path=/; SameSite=Strict` } }); } return new Response(renderLoginHTML('Invalid credentials'), { status: 401, headers: { 'Content-Type': 'text/html' } }); } async function verifySession(req, env) { const cookie = req.headers.get('Cookie'); return { valid: !!(cookie && cookie.includes('auth_token=')) }; } function formatSizeBytes(bytes) { if (!bytes || bytes === 0) return '0 B'; const k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } function getFileEmoji(ext) { const map = { mp4:'๐ฌ',webm:'๐ฌ',mkv:'๐ฌ',mp3:'๐ต',wav:'๐ต',ogg:'๐ต',flac:'๐ต',jpg:'๐ผ',jpeg:'๐ผ',png:'๐ผ',gif:'๐ผ',webp:'๐ผ',svg:'๐ผ',pdf:'๐',zip:'๐ฆ',rar:'๐ฆ',txt:'๐',json:'๐',md:'๐',js:'๐ป',html:'๐ป',css:'๐ป',py:'๐ป',php:'๐ป' }; return map[ext] || '๐'; } // ============================================ // HTML RENDERERS // ============================================ function renderLoginHTML(error = '') { return `
${message}
R2 Storage Manager ยท @kopikapal1