+578
-13
Diff
round #0
+49
frontend/index.html
+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
+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",
···
487
500
"undici-types": "~7.14.0"
488
501
}
489
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"
521
+
}
522
+
},
490
523
"node_modules/cross-spawn": {
491
524
"version": "7.0.6",
492
525
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+1
frontend/package.json
+1
frontend/package.json
+17
frontend/src/copyparty/bunny-image.ts
+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
+
}
+19
-5
frontend/src/gallery.mjs
+19
-5
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}`;
21
-
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);
20
+
const imageBase = process.env.IMAGE_BASE || apiBase.replace('/api', '/images');
21
+
const albumUrl = `${imageBase}/photos/${album}`;
22
+
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
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
+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
+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
+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
+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
+5
-1
server/.env.template
+2
server/deno.json
+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
+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: {
···
38
51
39
52
configureOpenApi(app);
40
53
54
+
app.route("/api/auth", auth);
55
+
41
56
const typedApp = app.openapi(
42
57
createRoute({
43
58
path: "/albums",
···
77
92
return c.text('Hello Hono!')
78
93
})
79
94
80
-
81
95
Deno.serve(app.fetch)
+30
server/src/auth.ts
+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
+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);