diff --git a/alx911.html b/alx911.html index aef3b05..b354fad 100644 --- a/alx911.html +++ b/alx911.html @@ -3,96 +3,98 @@ - ALX 911 Audio & Video Archive - danielmorrisey.com - - - - - - - - - - + ALX 911 - danielmorrisey.com + -

danielmorrisey.com/alx911

-

ALX 911 Archive

-

- A public archive of emergency communications and audio dispatches. Submit new records or listen to community archive files. -

+

ALX 911

+

ALX 911 is an online website where anyone can upload dispatch recordings, call info and in-person videos of emegerency vehicals responding to calls. Code 2 or Code 3.

- -
-
+ + diff --git a/cdn.html b/cdn.html index 4e39fb4..e1c4cbe 100644 --- a/cdn.html +++ b/cdn.html @@ -227,7 +227,14 @@ Content-Length: [file-size] [file-binary-data] - +
+
Example
+
danny@daniels-macbook-neo ~ % curl -X POST https://cdn.danielmorrisey.com/ \
+-H "Host: cdn.danielmorrisey.com" \
+-H "Content-Type: image/png" \
+--data-binary "@/Users/danny/Documents/Screenshot 2026-07-30 at 10.05.06.png"
+                    
+
Response (200 OK)
{
@@ -239,7 +246,17 @@ Content-Length: [file-size]
   "size": 2048576
 }
+
+
Example
+
{
+    "success":true,
+    "url":"https://cdn.danielmorrisey.com/user-content/2026-07-30/1cb23b40-7621-4b7e-b9a4-61e8d55d35a1.png",
+    "path":"user-content/2026-07-30/1cb23b40-7621-4b7e-b9a4-61e8d55d35a1.png",
+    "contentType":"image/png","fileType":"image","size":7619658
+}   
 
+                    
+
Rate Limiting (Response Headers)
X-RateLimit-Limit-Files: 30
@@ -250,7 +267,7 @@ X-RateLimit-Reset: 1719878400
-

Limits: 30 files/day, 100 MB/day, 100 MB/file per IP. Resets at midnight UTC.

+

Limits: 70 files/day, 300 MB/day, 100 MB/file per IP. Resets at midnight UTC.

diff --git a/cdn/api.html b/cdn/api.html new file mode 100644 index 0000000..a9b6be9 --- /dev/null +++ b/cdn/api.html @@ -0,0 +1,328 @@ + + + + + + MBD CDN API Docs - danielmorrisey.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+

danielmorrisey.com/cdn/api

+

MBD CDN API

+

+ A simple, fast, free CDN powered by Cloudflare R2, D1, and Workers. Upload alsomost anything in seconds, serve it forever. Free for life. +
+ Expercing Issues? Check our Status Page. +

+
+ + +
+ +
+
+
+
—
+
Images
+
+
+
+
—
+
Videos
+
+
+
+
—
+
GIFs
+
+
+
+
—
+
Documents
+
+
+
+
+
+
—
+
Storage Used
+
+
+
+ + +
+
+ + + + + +
+ + +
+ + +
+ + · + · +
+ +
+
+
+
+
0%
+
+ +
+
+
+ + + +
+ +
+
✓ Your file is live
+
+
+ + + Open + +
+
+
+ + +
+
+

The MBD CDN is a CDN built on Cloudflare's object storage, R2, a SQLight database, D1, and Cloudflare Workers. With zero cold starts and 100% uptime the MBD stays online to serve millions of people worldwide. +
+ Upload almost any file (not just images and videos) in seconds (even HTML files) and get a public url anyone can use. +

+
+
+ + +
+
+
+
1
+
+

You select a file

+

Your file is read locally in the browser and sent directly to the CDN API over HTTPS — straight to the Cloudflare edge, no intermediate servers.

+
+
+
+
2
+
+

A Worker receives it

+

A Cloudflare Worker handles the upload at the edge. It assigns a UUID filename, detects the file type, and streams the body directly into R2 with no cold starts.

+
+
+
+
3
+
+

R2 stores it permanently

+

The file is written to Cloudflare R2 — S3-compatible storage with zero egress fees and 11 nines of durability. Metadata is logged to D1 to track stats.

+
+
+
+
4
+
+

You get a public URL

+

A permanent cdn.danielmorrisey.com link is returned instantly. Anyone with it can access the file, served from whichever Cloudflare PoP is closest to them.

+
+
+
+ +
+
+ +

310+ Edge Locations

+

Files cached and served globally — sub-50ms for most users.

+
+
+ +

R2 Object Storage

+

Zero egress fees, no expiry. Enterprise-grade durability.

+
+
+ +

Zero Cold Starts

+

Workers run at the edge — every request is handled immediately.

+
+
+
+ + +
+
+

Build it into your app

+

The MBD CDN API is simple. Send a POST request with your file and get back a JSON response with your permanent URL, size, and file type.

+
+ +
+
POST Request
+
POST https://cdn.danielmorrisey.com/ HTTP/1.1
+Host: cdn.danielmorrisey.com
+Content-Type: [file-mime-type]
+Content-Length: [file-size]
+
+[file-binary-data]
+
+ +
+
Response (200 OK)
+
{
+  "success": true,
+  "url": "https://cdn.danielmorrisey.com/user-content/2026-06-01/uuid.ext",
+  "path": "user-content/2026-06-01/uuid.ext",
+  "contentType": "image/jpeg",
+  "fileType": "image",
+  "size": 2048576
+}
+
+ +
+
Rate Limiting (Response Headers)
+
X-RateLimit-Limit-Files: 30
+X-RateLimit-Remaining-Files: 25
+X-RateLimit-Limit-Bytes: 104857600
+X-RateLimit-Remaining-Bytes: 98765432
+X-RateLimit-Reset: 1719878400
+
+ +
+

Limits: 30 files/day, 100 MB/day, 100 MB/file per IP. Resets at midnight UTC.

+
+
+ + +
+
+
+ +
—
+
Max File Size
+
Per individual upload
+
+
+ +
—
+
Daily Storage
+
Total uploads per day
+
+
+ +
—
+
Uploads Per Day
+
Resets at midnight UTC
+
+
+ +
+ All limits reset daily at midnight UTC and are enforced per IP to protect performance for all users. +

Need higher limits? Try the developer API.

+
+ +
+ +
Loading your usage…
+ +
+ +
+ + +

+ Images — JPEG, PNG, WebP, AVIF, SVG  ·  + Animated — GIF  ·  + Video — MP4, WebM, MOV  ·  + Documents — PDF, JSON, HTML, CSS, JS, CSV, Markdown +

+
+ +
+
+ + + + + + \ No newline at end of file diff --git a/css/alx911.css b/css/alx911.css index 0f1807e..d06b208 100644 --- a/css/alx911.css +++ b/css/alx911.css @@ -261,4 +261,121 @@ flex-direction: column; gap: 0.25rem; } +} +/* User Status Bar */ +.user-auth-bar { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-card); + border: 1px solid var(--border); + padding: 0.75rem 1rem; + border-radius: var(--radius); + margin-bottom: 1.5rem; + font-size: 0.875rem; +} + +.auth-btn { + background: var(--bg-raised); + color: var(--text); + border: 1px solid var(--border); + padding: 0.4rem 0.8rem; + border-radius: 6px; + cursor: pointer; +} + +.auth-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* Call Card Delete Button */ +.call-author-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.75rem; + color: var(--text-dim); + margin-bottom: 0.25rem; +} + +.delete-btn { + background: rgba(224, 86, 86, 0.15); + color: #f88; + border: 1px solid #e05656; + border-radius: 4px; + padding: 0.2rem 0.5rem; + cursor: pointer; + font-size: 0.75rem; +} + +.delete-btn:hover { + background: #e05656; + color: #fff; +} + +/* Modal Styling */ +.modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + width: 100%; + max-width: 360px; + position: relative; +} + +.modal-close { + position: absolute; + top: 0.75rem; + right: 0.75rem; + background: none; + border: none; + color: var(--text-dim); + font-size: 1.25rem; + cursor: pointer; +} + +.modal-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--border); + padding-bottom: 0.5rem; +} + +.tab-btn { + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + font-weight: 600; +} + +.tab-btn.active { + color: var(--accent); +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.auth-form input { + background: var(--bg-raised); + border: 1px solid var(--border); + color: var(--text); + padding: 0.6rem; + border-radius: 6px; } \ No newline at end of file diff --git a/js/alx911.js b/js/alx911.js index 994a360..67712df 100644 --- a/js/alx911.js +++ b/js/alx911.js @@ -1,7 +1,5 @@ (() => { - // --- API Configuration --- const API_BASE_URL = 'https://alx911-api.danielmorrisey.com'; - let nextCursor = null; // Elements @@ -12,66 +10,171 @@ const loadMoreContainer = document.getElementById('load-more-container'); const loadMoreBtn = document.getElementById('load-more-btn'); - // UI Utilities + // Auth Elements + const authModal = document.getElementById('auth-modal'); + const openAuthBtn = document.getElementById('open-auth-btn'); + const closeAuthBtn = document.getElementById('close-auth-modal'); + const logoutBtn = document.getElementById('logout-btn'); + const userDisplay = document.getElementById('user-display'); + const authAlert = document.getElementById('auth-alert'); + + const tabLogin = document.getElementById('tab-login'); + const tabRegister = document.getElementById('tab-register'); + const tabReset = document.getElementById('tab-reset'); + + const loginForm = document.getElementById('login-form'); + const registerForm = document.getElementById('register-form'); + const forgotForm = document.getElementById('forgot-form'); + + // State + const getStoredAuth = () => { + try { + return JSON.parse(localStorage.getItem('alx_auth')) || null; + } catch { + return null; + } + }; + + const setStoredAuth = (auth) => { + if (auth) { + localStorage.setItem('alx_auth', JSON.stringify(auth)); + } else { + localStorage.removeItem('alx_auth'); + } + updateAuthUI(); + }; + + const updateAuthUI = () => { + const auth = getStoredAuth(); + if (auth && auth.user) { + userDisplay.textContent = `Signed in as @${auth.user.username}`; + openAuthBtn.classList.add('hidden'); + logoutBtn.classList.remove('hidden'); + } else { + userDisplay.textContent = 'Not signed in'; + openAuthBtn.classList.remove('hidden'); + logoutBtn.classList.add('hidden'); + } + }; + + // UI Helpers const showAlert = (message, type = 'error') => { alertBox.className = `alert alert-${type}`; alertBox.textContent = message; alertBox.classList.remove('hidden'); }; - const hideAlert = () => { - alertBox.classList.add('hidden'); + const showAuthAlert = (message, type = 'error') => { + authAlert.className = `alert alert-${type}`; + authAlert.textContent = message; + authAlert.classList.remove('hidden'); }; - // File Input Visual Feedback - const setupDropzone = (dropzoneId, inputId) => { - const dropzone = document.getElementById(dropzoneId); - const input = document.getElementById(inputId); - if (!dropzone || !input) return; - - const label = dropzone.querySelector('.file-label'); - - input.addEventListener('change', () => { - if (input.files.length > 0) { - label.textContent = input.files[0].name; - } else { - label.textContent = inputId === 'audio' - ? 'Choose or drag audio file' - : 'Choose or drag video file'; - } - }); + // Modal Tab Handler + const setTab = (tab) => { + authAlert.classList.add('hidden'); + tabLogin.classList.toggle('active', tab === 'login'); + tabRegister.classList.toggle('active', tab === 'register'); + tabReset.classList.toggle('active', tab === 'reset'); - ['dragenter', 'dragover'].forEach(eventName => { - dropzone.addEventListener(eventName, (e) => { - e.preventDefault(); - dropzone.classList.add('dragover'); - }, false); - }); + loginForm.classList.toggle('hidden', tab !== 'login'); + registerForm.classList.toggle('hidden', tab !== 'register'); + forgotForm.classList.toggle('hidden', tab !== 'reset'); + }; - ['dragleave', 'drop'].forEach(eventName => { - dropzone.addEventListener(eventName, (e) => { - e.preventDefault(); - dropzone.classList.remove('dragover'); - }, false); - }); + if (tabLogin) tabLogin.onclick = () => setTab('login'); + if (tabRegister) tabRegister.onclick = () => setTab('register'); + if (tabReset) tabReset.onclick = () => setTab('reset'); + + if (openAuthBtn) openAuthBtn.onclick = () => { authModal.classList.remove('hidden'); setTab('login'); }; + if (closeAuthBtn) closeAuthBtn.onclick = () => authModal.classList.add('hidden'); + if (logoutBtn) logoutBtn.onclick = () => { setStoredAuth(null); fetchCalls(); }; + + // Login Handler + loginForm.onsubmit = async (e) => { + e.preventDefault(); + try { + const res = await fetch(`${API_BASE_URL}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + emailOrUsername: document.getElementById('login-user').value, + password: document.getElementById('login-pass').value + }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Login failed'); + + setStoredAuth(data); + authModal.classList.add('hidden'); + fetchCalls(); + } catch (err) { + showAuthAlert(err.message); + } }; - setupDropzone('audio-dropzone', 'audio'); - setupDropzone('video-dropzone', 'video'); + // Register Handler + registerForm.onsubmit = async (e) => { + e.preventDefault(); + try { + const res = await fetch(`${API_BASE_URL}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: document.getElementById('reg-user').value, + email: document.getElementById('reg-email').value, + password: document.getElementById('reg-pass').value + }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Registration failed'); - const escapeHtml = (str) => { - return str.replace(/[&<>"']/g, (m) => { - return { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }[m]; - }); + setStoredAuth(data); + authModal.classList.add('hidden'); + fetchCalls(); + } catch (err) { + showAuthAlert(err.message); + } }; - // Render HTML for a Single Call Card + // Forgot Password Handler + forgotForm.onsubmit = async (e) => { + e.preventDefault(); + try { + const res = await fetch(`${API_BASE_URL}/api/auth/request-reset`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: document.getElementById('forgot-email').value }) + }); + const data = await res.json(); + showAuthAlert(data.message, 'success'); + } catch (err) { + showAuthAlert(err.message); + } + }; + + // Delete Call Handler + const deleteCall = async (callId) => { + if (!confirm('Are you sure you want to delete this record?')) return; + + const auth = getStoredAuth(); + if (!auth) return alert('Please sign in first.'); + + try { + const res = await fetch(`${API_BASE_URL}/api/calls/${callId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${auth.token}` } + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to delete call'); + + fetchCalls(); + } catch (err) { + alert(err.message); + } + }; + + // Card Renderer const createCallCard = (call) => { const card = document.createElement('div'); card.className = 'call-card'; @@ -84,48 +187,56 @@ const audioUrl = `${API_BASE_URL}${call.audioUrl}`; const videoUrl = call.videoUrl ? `${API_BASE_URL}${call.videoUrl}` : null; - let videoHtml = ''; - if (videoUrl) { - videoHtml = ` - - `; + let deleteBtnHtml = ''; + if (call.isOwner) { + deleteBtnHtml = ``; } card.innerHTML = ` +
+ Posted by @${call.author} + ${deleteBtnHtml} +

${escapeHtml(call.title)}

${formattedDate}
${call.description ? `
${escapeHtml(call.description)}
` : ''}
- - ${videoHtml} + + ${videoUrl ? `` : ''}
`; + const deleteBtn = card.querySelector('.delete-btn'); + if (deleteBtn) { + deleteBtn.onclick = () => deleteCall(call.id); + } + return card; }; + const escapeHtml = (str) => { + return str.replace(/[&<>"']/g, (m) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[m])); + }; + // API: Fetch Calls List const fetchCalls = async (cursor = null) => { try { const url = new URL(`${API_BASE_URL}/api/calls`); if (cursor) url.searchParams.set('cursor', cursor); - const res = await fetch(url.toString()); + const headers = {}; + const auth = getStoredAuth(); + if (auth) headers['Authorization'] = `Bearer ${auth.token}`; + + const res = await fetch(url.toString(), { headers }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); - - if (!cursor) { - callsContainer.innerHTML = ''; - } + if (!cursor) callsContainer.innerHTML = ''; if (data.items.length === 0 && !cursor) { callsContainer.innerHTML = '
No call records found.
'; @@ -133,53 +244,42 @@ return; } - data.items.forEach(item => { - callsContainer.appendChild(createCallCard(item)); - }); + data.items.forEach(item => callsContainer.appendChild(createCallCard(item))); nextCursor = data.nextCursor; - if (nextCursor) { - loadMoreContainer.classList.remove('hidden'); - } else { - loadMoreContainer.classList.add('hidden'); - } + loadMoreContainer.classList.toggle('hidden', !nextCursor); } catch (err) { console.error('Failed to load calls:', err); - if (!cursor) { - callsContainer.innerHTML = '
Failed to load recordings.
'; - } } }; - // API: Form Submit Handler + // Upload Form Submit if (form) { form.addEventListener('submit', async (e) => { e.preventDefault(); - hideAlert(); + const auth = getStoredAuth(); + if (!auth) { + showAlert('You must be signed in to submit recordings.'); + openAuthBtn.click(); + return; + } const formData = new FormData(form); - submitBtn.disabled = true; submitBtn.innerHTML = ' Uploading...'; try { const res = await fetch(`${API_BASE_URL}/api/calls`, { method: 'POST', + headers: { 'Authorization': `Bearer ${auth.token}` }, body: formData }); const result = await res.json(); - - if (!res.ok) { - throw new Error(result.error || 'Failed to submit recording.'); - } + if (!res.ok) throw new Error(result.error || 'Failed to submit recording.'); showAlert('Record successfully submitted!', 'success'); form.reset(); - document.querySelector('#audio-dropzone .file-label').textContent = 'Choose or drag audio file'; - document.querySelector('#video-dropzone .file-label').textContent = 'Choose or drag video file'; - - // Refresh call feed instantly fetchCalls(); } catch (err) { showAlert(err.message || 'An error occurred while uploading.'); @@ -190,15 +290,11 @@ }); } - // Pagination Load More if (loadMoreBtn) { - loadMoreBtn.addEventListener('click', () => { - if (nextCursor) { - fetchCalls(nextCursor); - } - }); + loadMoreBtn.onclick = () => { if (nextCursor) fetchCalls(nextCursor); }; } - // Initial Load + // Init + updateAuthUI(); fetchCalls(); })(); \ No newline at end of file