an attempt at a lightweight photo/album viewer

boilerplate

+577 -12
+49
frontend/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Photo Gallery Login</title> 7 + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> 8 + </head> 9 + <body> 10 + <div id="loginContainer" class="container mt-5"> 11 + <div class="row justify-content-center"> 12 + <div class="col-md-6"> 13 + <div class="card"> 14 + <div class="card-header"> 15 + <h3 class="text-center">Login</h3> 16 + </div> 17 + <div class="card-body"> 18 + <form id="loginForm"> 19 + <div class="mb-3"> 20 + <label for="email" class="form-label">Email address</label> 21 + <input type="email" class="form-control" id="email" required> 22 + </div> 23 + <button type="submit" class="btn btn-primary w-100" id="sendOtpBtn">Send OTP</button> 24 + </form> 25 + <form id="otpForm" style="display: none;"> 26 + <div class="mb-3"> 27 + <label for="otp" class="form-label">Enter OTP</label> 28 + <input type="text" class="form-control" id="otp" required> 29 + </div> 30 + <button type="submit" class="btn btn-success w-100" id="verifyOtpBtn">Verify OTP</button> 31 + </form> 32 + <div id="message" class="mt-3"></div> 33 + </div> 34 + </div> 35 + </div> 36 + </div> 37 + </div> 38 + <div id="galleryContainer" style="display: none;"> 39 + <h1 id="album-title"></h1> 40 + <div id="scrobbler-container"> 41 + <div id="scrobbler-handle-container"> 42 + <div id="scrobbler-section-title"></div> 43 + </div> 44 + </div> 45 + <div id="grid"></div> 46 + </div> 47 + <script type="module" src="dist/app.js"></script> 48 + </body> 49 + </html>
+33
frontend/package-lock.json
··· 11 11 "devDependencies": { 12 12 "@playwright/test": "^1.56.0", 13 13 "@types/node": "^24.8.1", 14 + "bootstrap": "^5.3.0", 14 15 "dotenv": "^17.2.3", 15 16 "dotenv-cli": "^10.0.0", 16 17 "esbuild": "^0.25.10", ··· 477 478 "node": ">=18" 478 479 } 479 480 }, 481 + "node_modules/@popperjs/core": { 482 + "version": "2.11.8", 483 + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", 484 + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", 485 + "dev": true, 486 + "license": "MIT", 487 + "peer": true, 488 + "funding": { 489 + "type": "opencollective", 490 + "url": "https://opencollective.com/popperjs" 491 + } 492 + }, 480 493 "node_modules/@types/node": { 481 494 "version": "24.8.1", 482 495 "resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz", ··· 485 498 "license": "MIT", 486 499 "dependencies": { 487 500 "undici-types": "~7.14.0" 501 + } 502 + }, 503 + "node_modules/bootstrap": { 504 + "version": "5.3.8", 505 + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", 506 + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", 507 + "dev": true, 508 + "funding": [ 509 + { 510 + "type": "github", 511 + "url": "https://github.com/sponsors/twbs" 512 + }, 513 + { 514 + "type": "opencollective", 515 + "url": "https://opencollective.com/bootstrap" 516 + } 517 + ], 518 + "license": "MIT", 519 + "peerDependencies": { 520 + "@popperjs/core": "^2.11.8" 488 521 } 489 522 }, 490 523 "node_modules/cross-spawn": {
+1
frontend/package.json
··· 16 16 "devDependencies": { 17 17 "@playwright/test": "^1.56.0", 18 18 "@types/node": "^24.8.1", 19 + "bootstrap": "^5.3.0", 19 20 "dotenv": "^17.2.3", 20 21 "dotenv-cli": "^10.0.0", 21 22 "esbuild": "^0.25.10",
+17
frontend/src/copyparty/bunny-image.ts
··· 1 + import type { ImageUrlProvider } from "../image-url-provider.js" 2 + 3 + export class BunnyImage implements ImageUrlProvider { 4 + private baseUrl: string 5 + 6 + constructor(baseUrl: string) { 7 + this.baseUrl = baseUrl 8 + } 9 + 10 + thumbnail(): string { 11 + return `${this.baseUrl}?w=300&h=200` 12 + } 13 + 14 + img(): string { 15 + return this.baseUrl 16 + } 17 + }
+18 -4
frontend/src/gallery.mjs
··· 17 17 const apiBase = process.env.API_ENDPOINT_POSTFIX ? 18 18 `${window.location.protocol}//${window.location.hostname}${process.env.API_ENDPOINT_POSTFIX}` 19 19 : process.env.API_ENDPOINT; 20 - const albumUrl = `${apiBase}/photos/${album}`; 20 + const imageBase = process.env.IMAGE_BASE || apiBase.replace('/api', '/images'); 21 + const albumUrl = `${imageBase}/photos/${album}`; 21 22 22 - let thumbUrlParams = "th=wf3&cache=i&_=1liSY&raster" 23 - let sectionStore = () => fetch(`${albumUrl}/store.geo.json`).then(res => res.json()); 24 - //let sectionStore = () => fetch(`${process.env.METADATA_API}/album/1`).then(res => res.json()).then(alb => alb.sections); 23 + let thumbUrlParams = "w=300&h=200" 24 + let sectionStore = () => fetch(`${process.env.METADATA_API}/album/${album}`).then(res => res.json()).then(albumData => { 25 + return albumData.sections.map(section => ({ 26 + sectionId: section.id, 27 + totalImages: section.segments.reduce((sum, seg) => sum + seg.images.length, 0), 28 + segments: section.segments.map(seg => ({ 29 + segmentId: seg.id, 30 + header: `${seg.month} ${seg.day}, ${seg.year}`, 31 + images: seg.images.map(img => ({ 32 + filename: img.fileName, 33 + timestamp: new Date(img.dateCreated * 1000).toISOString(), 34 + metadata: img.metadata 35 + })) 36 + })) 37 + })); 38 + }); 25 39 let regionStore = `${apiBase}/${process.env.GEO_API_ENDPOINT}` 26 40 const geo = new Geo(regionStore); 27 41
+2 -2
frontend/src/geo.ts
··· 2 2 // #ISO ISO3 ISO-Numeric fips Country Capital Area(in sq km) Population Continent tld CurrencyCode CurrencyName Phone Postal Code Format Postal Code Regex Languages geonameid neighbours EquivalentFipsCode 3 3 // AD AND 020 AN Andorra Andorra la Vella 468 77006 EU .ad EUR Euro 376 AD### ^(?:AD)*(\d{3})$ ca 3041565 ES,FR 4 4 // @ts-ignore 5 - import countryInfo from 'https://download.geonames.org/export/dump/countryInfo.txt?sha256=8e2d031441e14c2b0b012b6b42ed94bfb4be7b98911df4b4d3121cd71e06512a'; 5 + import countryInfo from 'https://download.geonames.org/export/dump/countryInfo.txt?sha256=486ca2fb0f38f58d857013493e03b79f7db912167303587198d70132726c1eb4'; 6 6 7 7 export type UnresolvedCountryCode = string; 8 8 // all these types are useless? - see user defined type guards... ··· 110 110 return Geo.MISSING_REGION_DATA(cc); 111 111 }); 112 112 } 113 - } 113 + }
+74 -4
frontend/src/index.ts
··· 2 2 3 3 import listAlbums from './albums.js' 4 4 5 + const apiBase = process.env.API_ENDPOINT_POSTFIX ? 6 + `${window.location.protocol}//${window.location.hostname}${process.env.API_ENDPOINT_POSTFIX}` 7 + : process.env.API_ENDPOINT; 8 + 5 9 function init() { 6 - window.onload = loadUi; 7 - window.onresize = loadUi; 8 - register(); 9 - loadUi(); 10 + const loginContainer = document.getElementById('loginContainer') as HTMLElement; 11 + const galleryContainer = document.getElementById('galleryContainer') as HTMLElement; 12 + const loginForm = document.getElementById('loginForm') as HTMLFormElement; 13 + const otpForm = document.getElementById('otpForm') as HTMLFormElement; 14 + const messageDiv = document.getElementById('message') as HTMLElement; 15 + 16 + if (!loginContainer || !galleryContainer || !loginForm || !otpForm || !messageDiv) return; 17 + 18 + // Check if already authenticated 19 + if (localStorage.getItem('authSession')) { 20 + showGallery(); 21 + return; 22 + } 23 + 24 + loginForm.addEventListener('submit', async (e) => { 25 + e.preventDefault(); 26 + const emailInput = document.getElementById('email') as HTMLInputElement; 27 + if (!emailInput) return; 28 + const email = emailInput.value; 29 + try { 30 + const response = await fetch(`${apiBase}/api/auth/sign-in/email`, { 31 + method: 'POST', 32 + headers: { 'Content-Type': 'application/json' }, 33 + body: JSON.stringify({ email }) 34 + }); 35 + if (response.ok) { 36 + messageDiv.textContent = 'OTP sent to your email.'; 37 + loginForm.style.display = 'none'; 38 + otpForm.style.display = 'block'; 39 + } else { 40 + messageDiv.textContent = 'Error sending OTP.'; 41 + } 42 + } catch (error) { 43 + messageDiv.textContent = 'Network error.'; 44 + } 45 + }); 46 + 47 + otpForm.addEventListener('submit', async (e) => { 48 + e.preventDefault(); 49 + const emailInput = document.getElementById('email') as HTMLInputElement; 50 + const otpInput = document.getElementById('otp') as HTMLInputElement; 51 + if (!emailInput || !otpInput) return; 52 + const email = emailInput.value; 53 + const otp = otpInput.value; 54 + try { 55 + const response = await fetch(`${apiBase}/api/auth/callback/email`, { 56 + method: 'POST', 57 + headers: { 'Content-Type': 'application/json' }, 58 + body: JSON.stringify({ email, token: otp }) 59 + }); 60 + if (response.ok) { 61 + const data = await response.json(); 62 + localStorage.setItem('authSession', JSON.stringify(data.session)); 63 + showGallery(); 64 + } else { 65 + messageDiv.textContent = 'Invalid OTP.'; 66 + } 67 + } catch (error) { 68 + messageDiv.textContent = 'Network error.'; 69 + } 70 + }); 71 + 72 + function showGallery() { 73 + loginContainer.style.display = 'none'; 74 + galleryContainer.style.display = 'block'; 75 + window.onload = loadUi; 76 + window.onresize = loadUi; 77 + register(); 78 + loadUi(); 79 + } 10 80 } 11 81 12 82 function albumList() {
+57
frontend/test/auth-e2e.spec.ts
··· 1 + import { test as base, expect } from '@playwright/test'; 2 + 3 + import { RealBackend } from '@testFixtures/real-backend'; 4 + import { FrontendUrlBuilder } from '@testFixtures/frontend-url-builder'; 5 + import { Screenshotter } from '@testFixtures/screenshotter'; 6 + 7 + import { serve } from '../esb/build.mjs'; 8 + 9 + const GALLERY_1 = "1"; 10 + 11 + const test = base.extend<{ 12 + backend: RealBackend, 13 + screenie: Screenshotter, 14 + urls: FrontendUrlBuilder 15 + }>({ 16 + backend: async ({ page }, use) => { 17 + const backend = new RealBackend(); 18 + await backend.setup(); 19 + const { context, proxyServer } = await serve(); 20 + 21 + await backend.waitForServer('localhost', 8000); // Assuming backend runs on 8000 22 + 23 + await use(backend); 24 + 25 + proxyServer.close(); 26 + await context.dispose(); 27 + await backend.stop(); 28 + }, 29 + screenie: async ({ page }, use, testInfo) => { 30 + await use(new Screenshotter(page, testInfo.title, false)); 31 + }, 32 + urls: async ({ page }, use) => { 33 + await use(new FrontendUrlBuilder()); 34 + } 35 + }); 36 + 37 + test.use(({ viewport: { width: 480, height: 760 } })); 38 + 39 + test('login and view gallery', async ({ backend, page }) => { 40 + const baseUrl = `http://localhost:${process.env.FRONTEND_DEV_PORT}`; 41 + await page.goto(baseUrl); // Login page 42 + 43 + // Fill email 44 + await page.fill('#email', 'test@example.com'); 45 + 46 + // Submit to send OTP 47 + await page.click('#sendOtpBtn'); 48 + 49 + // Wait for message 50 + await page.waitForSelector('#message', { timeout: 5000 }); 51 + 52 + // Assume OTP is sent, for test, we can't actually receive SMS, so mock or skip 53 + // In real test, would need to intercept or have test OTP 54 + 55 + // For now, just check the UI flow 56 + expect(await page.isVisible('#otpForm')).toBe(true); 57 + });
+134
frontend/test/fixtures/real-backend.ts
··· 1 + import { spawn } from 'node:child_process'; 2 + import path from 'path'; 3 + import net from 'net'; 4 + import { createServer } from "http"; 5 + import url from 'url'; 6 + import crypto from 'crypto'; 7 + 8 + import { waitFor } from './await' 9 + import { colours, colourNames } from './colours.mjs' 10 + 11 + const BACKEND_TEST_PORTS = ['8001', '8002', '8003']; 12 + 13 + export class RealBackend { 14 + 15 + private readonly host = 'localhost'; 16 + 17 + private processes: any = []; 18 + private backendPort!: number; 19 + private imagePort!: number; 20 + 21 + getBackendPort(): number { 22 + return this.backendPort; 23 + } 24 + 25 + getImagePort(): number { 26 + return this.imagePort; 27 + } 28 + 29 + constructor() {} 30 + 31 + async setup() { 32 + [this.backendPort, this.imagePort] = await Promise.all([ 33 + this.findFreePort(BACKEND_TEST_PORTS.map(Number)), 34 + this.findFreePort(BACKEND_TEST_PORTS.map(Number)), 35 + ]); 36 + 37 + console.log(`Using ports: backend ${this.backendPort}, image ${this.imagePort}`); 38 + 39 + // Start servers 40 + await this.startBackend(); 41 + await this.startImageServer(); 42 + 43 + // Wait for servers to be ready 44 + console.log('Waiting for servers...'); 45 + await Promise.all([ 46 + this.waitForServer(this.host, this.backendPort), 47 + ]); 48 + console.log('Servers ready.'); 49 + } 50 + 51 + async startBackend() { 52 + const serverDir = path.resolve(__dirname, '../../server'); 53 + const env = { 54 + ...process.env, 55 + PORT: this.backendPort.toString(), 56 + // Add other env vars as needed 57 + }; 58 + 59 + const proc = spawn('deno', ['run', '--allow-net', '--allow-read=.env', '--allow-env', 'main.ts'], { 60 + cwd: serverDir, 61 + env, 62 + stdio: 'inherit' 63 + }); 64 + 65 + this.processes.push(proc); 66 + } 67 + 68 + async startImageServer() { 69 + const server = createServer((req, res) => { 70 + // Enable CORS for all requests 71 + res.setHeader('Access-Control-Allow-Origin', '*'); 72 + res.setHeader('Access-Control-Headers', '*'); 73 + res.setHeader('Access-Control-Allow-Methods', '*'); 74 + 75 + // Handle preflight OPTIONS 76 + if (req.method === 'OPTIONS') { 77 + res.writeHead(200); 78 + res.end(); 79 + return; 80 + } 81 + 82 + if (req.method === 'GET' && req.url && url.parse(req.url).pathname?.endsWith('.jpg')) { 83 + const filename = req.url.split('/').pop() || ''; 84 + const hash = crypto.createHash('sha256').update(filename).digest('hex'); 85 + const index = parseInt(hash.substring(0, 4), 16) % colours.length; 86 + console.log(`serve img: ${colourNames[index]}`) 87 + res.writeHead(200, { 'Content-Type': 'image/png' }); 88 + res.end(colours[index]); 89 + } else { 90 + res.writeHead(404, { 'Content-Type': 'application/json' }); 91 + res.end(JSON.stringify({ error: 'Not found' })); 92 + } 93 + }); 94 + 95 + server.listen(this.imagePort); 96 + this.processes.push(server); 97 + } 98 + 99 + async findFreePort(portRanges: number[]): Promise<number> { 100 + for (const port of portRanges) { 101 + const available = await new Promise((resolve) => { 102 + const server = net.createServer().listen(port, () => { 103 + server.close(() => resolve(true)); 104 + }).on('error', () => { 105 + console.log(`Port ${port} is in use`); 106 + resolve(false)}); 107 + }); 108 + if (available) return port; 109 + } 110 + throw new Error('No free ports found'); 111 + } 112 + 113 + async waitForServer(host: string, port: number, timeout = 10000) { 114 + return waitFor(async () => { 115 + try { 116 + await new Promise<void>((resolve, reject) => { 117 + const client = net.createConnection({ host, port }, () => { 118 + client.end(); 119 + resolve(); 120 + }).on('error', reject); 121 + }); 122 + return port; 123 + } catch (err) { 124 + return null; 125 + } 126 + }, "Server not ready on port " + port, 100, timeout); 127 + }; 128 + 129 + stop() { 130 + this.processes.forEach((proc: any) => { 131 + proc.kill(); 132 + }); 133 + } 134 + }
+130
frontend/test/gallery-view-mobile-real.spec.ts
··· 1 + import { test as base, expect } from '@playwright/test'; 2 + 3 + import { RealBackend } from '@testFixtures/real-backend'; 4 + import { FrontendUrlBuilder } from '@testFixtures/frontend-url-builder'; 5 + import { Screenshotter } from '@testFixtures/screenshotter'; 6 + 7 + import { serve } from '../esb/build.mjs'; 8 + 9 + const GALLERY_1 = "1"; 10 + const GALLERY_2 = "2"; 11 + const GALLERY_3 = "3"; 12 + 13 + const THRESHOLD = 0.01; // 0.1% threshold for visual differences 14 + 15 + const test = base.extend<{ 16 + backend: RealBackend, 17 + screenie: Screenshotter, 18 + urls: FrontendUrlBuilder 19 + }>({ 20 + backend: async ({ page }, use) => { 21 + const backend = new RealBackend(); 22 + await backend.setup(); 23 + process.env.METADATA_API = `http://localhost:${backend.getBackendPort()}`; 24 + process.env.IMAGE_BASE = `http://localhost:${backend.getImagePort()}`; 25 + const { context, proxyServer } = await serve(); 26 + 27 + // Set up test data 28 + await setupTestData(backend.getBackendPort()); 29 + 30 + await backend.waitForServer('localhost', Number(process.env.FRONTEND_DEV_PORT)) 31 + 32 + await use(backend); 33 + 34 + proxyServer.close(); 35 + await context.dispose(); 36 + await backend.stop(); 37 + }, 38 + screenie: async ({ page }, use, testInfo) => { 39 + await use(new Screenshotter(page, testInfo.title, false)); 40 + }, 41 + urls: async ({ page }, use) => { 42 + await use(new FrontendUrlBuilder()); 43 + } 44 + }); 45 + 46 + test.use(({ viewport: { width: 480, height: 760 } })); 47 + 48 + async function setupTestData(port: number) { 49 + const baseUrl = `http://localhost:${port}`; 50 + // Create test albums with photos 51 + const albums = [ 52 + { 53 + album: { slug: GALLERY_1, title: "Test Gallery 1", year: 2023 }, 54 + contents: Array.from({ length: 12 }, (_, i) => ({ 55 + fileName: `${i + 1}.jpg`, 56 + dateCreated: Date.now(), 57 + dateModified: Date.now(), 58 + metadata: { 59 + width: 1920, 60 + height: 1080, 61 + orientation: 1, 62 + region: "Test.Region" 63 + } 64 + })) 65 + }, 66 + { 67 + album: { slug: GALLERY_2, title: "Test Gallery 2", year: 2023 }, 68 + contents: Array.from({ length: 8 }, (_, i) => ({ 69 + fileName: `${i + 1}.jpg`, 70 + dateCreated: Date.now(), 71 + dateModified: Date.now(), 72 + metadata: { 73 + width: 1080, 74 + height: 1920, 75 + orientation: 1, 76 + region: "Test.Region" 77 + } 78 + })) 79 + }, 80 + { 81 + album: { slug: GALLERY_3, title: "Test Gallery 3", year: 2023 }, 82 + contents: Array.from({ length: 5 }, (_, i) => ({ 83 + fileName: `${i + 1}.jpg`, 84 + dateCreated: Date.now(), 85 + dateModified: Date.now(), 86 + metadata: { 87 + width: 1280, 88 + height: 720, 89 + orientation: 1, 90 + region: "Test.Region" 91 + } 92 + })) 93 + } 94 + ]; 95 + 96 + for (const album of albums) { 97 + const response = await fetch(`${baseUrl}/batchPhotosInAlbum`, { 98 + method: 'POST', 99 + headers: { 'Content-Type': 'application/json' }, 100 + body: JSON.stringify(album) 101 + }); 102 + if (!response.ok) { 103 + throw new Error(`Failed to create album ${album.album.slug}: ${response.statusText}`); 104 + } 105 + } 106 + } 107 + 108 + test('basic mobile view', async ({ backend, page, screenie, urls })=> { 109 + await page.goto(urls.galleryUrl(GALLERY_1)); 110 + await page.waitForResponse(/12\.jpg\?.*$/); 111 + const percentageDiff: number = await screenie.takeScreenshot('gallery'); 112 + expect(percentageDiff).toBeLessThan(THRESHOLD * 100); 113 + }); 114 + 115 + test('basic mobile view scrobbler hover', async ({ backend, page, screenie, urls })=> { 116 + await page.goto(urls.galleryUrl(GALLERY_1)); 117 + await page.waitForResponse(/12\.jpg\?.*$/); 118 + await page.locator("#scrobbler-container").hover(); 119 + const percentageDiff: number = await screenie.takeScreenshot('gallery'); 120 + expect(percentageDiff).toBeLessThan(THRESHOLD * 100); 121 + 122 + }); 123 + 124 + test('basic mobile view scrobblerdot hover', async ({ backend, page, screenie, urls })=> { 125 + await page.goto(urls.galleryUrl(GALLERY_1)); 126 + await page.waitForResponse(/12\.jpg\?.*$/); 127 + await page.locator("#scrobbler-handle").hover(); 128 + const percentageDiff: number = await screenie.takeScreenshot('gallery', false); 129 + expect(percentageDiff).toBeLessThan(THRESHOLD * 100); 130 + });
+5 -1
server/.env.template
··· 1 1 LOG_LEVEL=debug 2 - DATABASE_URL=file:dev.db 2 + DATABASE_URL=file:dev.db 3 + BASE_URL=http://localhost:8000 4 + TWILIO_ACCOUNT_SID=your_twilio_account_sid 5 + TWILIO_AUTH_TOKEN=your_twilio_auth_token 6 + TWILIO_FROM=your_twilio_phone_number
+2
server/deno.json
··· 8 8 "@scalar/hono-api-reference": "npm:@scalar/hono-api-reference@^0.9.28", 9 9 "@std/dotenv": "jsr:@std/dotenv@^0.225.5", 10 10 "@types/deno": "npm:@types/deno@^2.5.0", 11 + "better-auth": "npm:better-auth@^0.4.10", 11 12 "drizzle-orm": "npm:drizzle-orm@^1.0.0-beta.6-c513c71", 12 13 "drizzle-zod": "npm:drizzle-zod@^0.8.3", 13 14 "eslint": "npm:eslint@^9.39.2", ··· 18 19 "pino-pretty": "npm:pino-pretty@^13.1.3", 19 20 "postgres": "npm:postgres@^3.4.7", 20 21 "stoker": "npm:stoker@^2.0.1", 22 + "twilio": "npm:twilio@^5.4.0", 21 23 "typescript": "npm:typescript@^5.9.3", 22 24 "zod": "npm:zod@^4.1.13" 23 25 },
+15 -1
server/main.ts
··· 10 10 import jsonContent from "stoker/openapi/helpers/json-content"; 11 11 import db from "./src/db/index.ts"; 12 12 import { album } from "./src/db/schema/album.ts"; 13 + import { auth } from "./src/auth.ts"; 14 + 15 + // Placeholder images for testing 16 + const whitePNG = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJoGhsDAAA='), c => c.charCodeAt(0)); 17 + const redPNG = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P4z8DwHwAFAAH/F1FwBgAAAABJRU5ErkJggg=='), c => c.charCodeAt(0)); 18 + const greenPNG = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2Ng+M/wHwAEAQH/7yMK/gAAAABJRU5ErkJggg=='), c => c.charCodeAt(0)); 19 + const bluePNG = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgYP7/HwADCwICWSCp3wAAAABJRU5ErkJggg=='), c => c.charCodeAt(0)); 20 + const blackPNG = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=='), c => c.charCodeAt(0)); 21 + const grayPNG = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2MwMjL6DwACxgGWPfNfywAAAABJRU5ErkJggg=='), c => c.charCodeAt(0)); 22 + const orangePNG = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P4v5ThPwAG7wKklwQ/bwAAAABJRU5ErkJggg=='), c => c.charCodeAt(0)); 23 + 24 + const colours = [whitePNG, redPNG, greenPNG, bluePNG, blackPNG, grayPNG, orangePNG]; 25 + const colourNames = ['white', 'red', 'green', 'blue', 'black', 'gray', 'orange']; 13 26 14 27 interface AppBindings { 15 28 Variables: { ··· 37 50 const app = new OpenAPIHono<AppBindings>() 38 51 39 52 configureOpenApi(app); 53 + 54 + app.route("/api/auth", auth); 40 55 41 56 const typedApp = app.openapi( 42 57 createRoute({ ··· 76 91 app.get('/', (c) => { 77 92 return c.text('Hello Hono!') 78 93 }) 79 - 80 94 81 95 Deno.serve(app.fetch)
+30
server/src/auth.ts
··· 1 + import { betterAuth } from "better-auth"; 2 + import { emailOTP } from "better-auth/plugins"; 3 + import { drizzleAdapter } from "better-auth/adapters/drizzle"; 4 + import db from "./db/index.ts"; 5 + import * as schema from "./db/schema/index.ts"; 6 + import { twilio } from "./twilio-client.ts"; 7 + 8 + export const auth = betterAuth({ 9 + baseURL: Deno.env.get("BASE_URL") || "http://localhost:8000", 10 + database: drizzleAdapter(db, { 11 + provider: "sqlite", 12 + schema, 13 + }), 14 + emailAndPassword: { 15 + enabled: false, 16 + }, 17 + plugins: [ 18 + emailOTP({ 19 + async sendOTP({ email, otp, type }) { 20 + if (type === "sign-in") { 21 + await twilio.messages.create({ 22 + body: `Your OTP is: ${otp}`, 23 + from: Deno.env.get("TWILIO_FROM"), 24 + to: email, 25 + }); 26 + } 27 + }, 28 + }), 29 + ], 30 + });
+10
server/src/twilio-client.ts
··· 1 + import { Twilio } from "twilio"; 2 + 3 + const accountSid = Deno.env.get("TWILIO_ACCOUNT_SID"); 4 + const authToken = Deno.env.get("TWILIO_AUTH_TOKEN"); 5 + 6 + if (!accountSid || !authToken) { 7 + throw new Error("Twilio credentials not set"); 8 + } 9 + 10 + export const twilio = new Twilio(accountSid, authToken);