feat: add notes app

This commit is contained in:
2026-10-01 19:39:20 +02:00
parent ccb8dd3d15
commit ddbb6f37b5
35 changed files with 706 additions and 52 deletions
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod'
const FAILED_LOGIN_DELAY_MS = 1000
const loginSchema = z.object({
password: z.string().min(1)
})
export default defineEventHandler(async (event) => {
const { password } = await readValidBody(event, loginSchema)
if (!(await passwordMatches(event, password))) {
await new Promise(resolve => setTimeout(resolve, FAILED_LOGIN_DELAY_MS))
throw createError({ statusCode: 401, statusMessage: 'Wrong password' })
}
const session = await authSession(event)
await session.update({ unlocked: true })
return { unlocked: true }
})
+4
View File
@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const session = await authSession(event)
return { unlocked: !!session.data.unlocked }
})
+11
View File
@@ -0,0 +1,11 @@
const PROTECTED_PREFIXES = ['/api/diary/', '/api/notes/']
export default defineEventHandler(async (event) => {
const path = event.path.split('?')[0]!
if (!PROTECTED_PREFIXES.some(prefix => path.startsWith(prefix))) return
const session = await authSession(event)
if (!session.data.unlocked) {
throw createError({ statusCode: 401, statusMessage: 'Locked' })
}
})
+38
View File
@@ -0,0 +1,38 @@
import type { H3Event } from 'h3'
interface AuthSession {
unlocked?: boolean
}
const SESSION_MAX_AGE_S = 60 * 60 * 24 * 30
/** The login shared by all password-protected apps (see PROTECTED_PREFIXES in server/middleware/auth.ts) */
export function authSession(event: H3Event) {
const { sessionSecret } = useRuntimeConfig(event)
if (!sessionSecret || sessionSecret.length < 32) {
throw createError({ statusCode: 500, statusMessage: 'NUXT_SESSION_SECRET must be set (at least 32 characters)' })
}
return useSession<AuthSession>(event, {
// Kept from when only the diary had a login, so existing logins stay valid
name: 'diary',
password: sessionSecret,
maxAge: SESSION_MAX_AGE_S,
cookie: { httpOnly: true, sameSite: 'lax', secure: !import.meta.dev }
})
}
async function sha256(text: string): Promise<Uint8Array> {
return new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
}
export async function passwordMatches(event: H3Event, guess: string): Promise<boolean> {
const { diaryPassword } = useRuntimeConfig(event)
if (!diaryPassword) {
throw createError({ statusCode: 500, statusMessage: 'NUXT_DIARY_PASSWORD is not set' })
}
const [a, b] = await Promise.all([sha256(guess), sha256(diaryPassword)])
let difference = 0
for (let i = 0; i < a.length; i++) difference |= a[i]! ^ b[i]!
return difference === 0
}
+12 -4
View File
@@ -13,7 +13,7 @@ const TIMEOUT_MS = 15_000
async function request<T>(
event: H3Event,
path: string,
options: { method?: 'GET' | 'POST' | 'PATCH', query?: Fields, body?: Fields } = {}
options: { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE', query?: Fields, body?: Fields } = {}
): Promise<T> {
const { baserowUrl, baserowToken } = useRuntimeConfig(event)
if (!baserowUrl || !baserowToken) {
@@ -38,13 +38,13 @@ async function request<T>(
}
}
/** All rows of a table. `filters` are Baserow row filters, e.g. `{ filter__read__boolean: true }`. */
export async function listRows<Row>(event: H3Event, tableId: string, filters: Fields = {}): Promise<Row[]> {
/** All rows of a table. `query` adds Baserow list parameters, e.g. `{ filter__read__boolean: true }` or `{ include: 'title' }`. */
export async function listRows<Row>(event: H3Event, tableId: string, query: Fields = {}): Promise<Row[]> {
const path = `rows/table/${tableId}/`
const rows: Row[] = []
for (let page = 1; ; page++) {
const { count, results } = await request<RowPage<Row>>(event, path, {
query: { ...filters, user_field_names: true, size: PAGE_SIZE, page }
query: { ...query, user_field_names: true, size: PAGE_SIZE, page }
})
rows.push(...results)
if (rows.length >= count || results.length === 0) return rows
@@ -59,6 +59,14 @@ export function createRow<Row>(event: H3Event, tableId: string, fields: Fields):
})
}
export function getRow<Row>(event: H3Event, tableId: string, rowId: number): Promise<Row> {
return request<Row>(event, `rows/table/${tableId}/${rowId}/`, { query: { user_field_names: true } })
}
export async function deleteRow(event: H3Event, tableId: string, rowId: number): Promise<void> {
await request(event, `rows/table/${tableId}/${rowId}/`, { method: 'DELETE' })
}
export function updateRow<Row>(event: H3Event, tableId: string, rowId: number, fields: Fields): Promise<Row> {
return request<Row>(event, `rows/table/${tableId}/${rowId}/`, {
method: 'PATCH',
+10
View File
@@ -0,0 +1,10 @@
import type { H3Event } from 'h3'
import type { z } from 'zod'
export async function readValidBody<Schema extends z.ZodType>(event: H3Event, schema: Schema): Promise<z.output<Schema>> {
const result = schema.safeParse(await readBody(event))
if (!result.success) {
throw createError({ statusCode: 400, statusMessage: result.error.issues[0]?.message ?? 'Invalid request' })
}
return result.data
}