feat: add wishlist
This commit is contained in:
@@ -1,14 +0,0 @@
|
||||
const FAILED_LOGIN_DELAY_MS = 1000
|
||||
|
||||
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 diarySession(event)
|
||||
await session.update({ unlocked: true })
|
||||
return { unlocked: true }
|
||||
})
|
||||
@@ -1,4 +0,0 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const rows = await listRows<NoteRow>(event, 'notes')
|
||||
return rows.map(toNote).filter(note => note.date)
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const input = await readValidBody(event, newNoteSchema)
|
||||
const row = await createRow<NoteRow>(event, 'notes', {
|
||||
Datum: input.date,
|
||||
Notes: input.text
|
||||
})
|
||||
return toNote(row)
|
||||
})
|
||||
@@ -1,4 +0,0 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const rows = await listRows<ReviewRow>(event, 'reviews')
|
||||
return rows.map(toReview).filter(review => review.date)
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const input = await readValidBody(event, newReviewSchema)
|
||||
const row = await createRow<ReviewRow>(event, 'reviews', {
|
||||
title: input.title,
|
||||
date: input.date,
|
||||
author_or_director: input.author ?? '',
|
||||
review: input.text,
|
||||
medium: await mediumOptionId(event, input.medium)
|
||||
})
|
||||
return toReview(row)
|
||||
})
|
||||
@@ -1,4 +0,0 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await diarySession(event)
|
||||
return { unlocked: !!session.data.unlocked }
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
const PUBLIC_ROUTES = ['/api/session', '/api/login']
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const path = event.path.split('?')[0]!
|
||||
if (!path.startsWith('/api/') || PUBLIC_ROUTES.includes(path)) return
|
||||
|
||||
const session = await diarySession(event)
|
||||
if (!session.data.unlocked) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'The diary is locked' })
|
||||
}
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
interface DiarySession {
|
||||
unlocked?: boolean
|
||||
}
|
||||
|
||||
const SESSION_MAX_AGE_S = 60 * 60 * 24 * 30
|
||||
|
||||
export function diarySession(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<DiarySession>(event, {
|
||||
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
|
||||
}
|
||||
+15
-17
@@ -1,6 +1,5 @@
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
type Table = 'reviews' | 'notes'
|
||||
type Fields = Record<string, unknown>
|
||||
|
||||
interface RowPage<Row> {
|
||||
@@ -11,19 +10,10 @@ interface RowPage<Row> {
|
||||
const PAGE_SIZE = 200
|
||||
const TIMEOUT_MS = 15_000
|
||||
|
||||
function tableId(event: H3Event, table: Table): string {
|
||||
const config = useRuntimeConfig(event)
|
||||
const id = { reviews: config.baserowReviewsTableId, notes: config.baserowNotesTableId }[table]
|
||||
if (!id) {
|
||||
throw createError({ statusCode: 500, statusMessage: `NUXT_BASEROW_${table.toUpperCase()}_TABLE_ID is not set` })
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
event: H3Event,
|
||||
path: string,
|
||||
options: { method?: 'GET' | 'POST', query?: Fields, body?: Fields } = {}
|
||||
options: { method?: 'GET' | 'POST' | 'PATCH', query?: Fields, body?: Fields } = {}
|
||||
): Promise<T> {
|
||||
const { baserowUrl, baserowToken } = useRuntimeConfig(event)
|
||||
if (!baserowUrl || !baserowToken) {
|
||||
@@ -48,8 +38,8 @@ async function request<T>(
|
||||
}
|
||||
}
|
||||
|
||||
export async function listRows<Row>(event: H3Event, table: Table): Promise<Row[]> {
|
||||
const path = `rows/table/${tableId(event, table)}/`
|
||||
export async function listRows<Row>(event: H3Event, tableId: string): Promise<Row[]> {
|
||||
const path = `rows/table/${tableId}/`
|
||||
const rows: Row[] = []
|
||||
for (let page = 1; ; page++) {
|
||||
const { count, results } = await request<RowPage<Row>>(event, path, {
|
||||
@@ -60,14 +50,22 @@ export async function listRows<Row>(event: H3Event, table: Table): Promise<Row[]
|
||||
}
|
||||
}
|
||||
|
||||
export function createRow<Row>(event: H3Event, table: Table, fields: Fields): Promise<Row> {
|
||||
return request<Row>(event, `rows/table/${tableId(event, table)}/`, {
|
||||
export function createRow<Row>(event: H3Event, tableId: string, fields: Fields): Promise<Row> {
|
||||
return request<Row>(event, `rows/table/${tableId}/`, {
|
||||
method: 'POST',
|
||||
query: { user_field_names: true },
|
||||
body: fields
|
||||
})
|
||||
}
|
||||
|
||||
export function listFields<Field>(event: H3Event, table: Table): Promise<Field[]> {
|
||||
return request<Field[]>(event, `fields/table/${tableId(event, table)}/`)
|
||||
export function updateRow<Row>(event: H3Event, tableId: string, rowId: number, fields: Fields): Promise<Row> {
|
||||
return request<Row>(event, `rows/table/${tableId}/${rowId}/`, {
|
||||
method: 'PATCH',
|
||||
query: { user_field_names: true },
|
||||
body: fields
|
||||
})
|
||||
}
|
||||
|
||||
export function listFields<Field>(event: H3Event, tableId: string): Promise<Field[]> {
|
||||
return request<Field[]>(event, `fields/table/${tableId}/`)
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export interface NoteRow {
|
||||
id: number
|
||||
Datum: string | null
|
||||
Notes: string | null
|
||||
}
|
||||
|
||||
export function toNote(row: NoteRow): Note {
|
||||
return {
|
||||
id: row.id,
|
||||
date: row.Datum ?? '',
|
||||
text: (row.Notes ?? '').trim()
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
export interface ReviewRow {
|
||||
id: number
|
||||
title: string | null
|
||||
date: string | null
|
||||
author_or_director: string | null
|
||||
review: string | null
|
||||
medium: SelectOption | null
|
||||
}
|
||||
|
||||
interface SelectOption {
|
||||
id: number
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Field {
|
||||
name: string
|
||||
select_options?: SelectOption[]
|
||||
}
|
||||
|
||||
const OPTION_CACHE_MS = 10 * 60_000
|
||||
|
||||
let mediumOptions: { ids: Partial<Record<Medium, number>>, fetchedAt: number } | null = null
|
||||
|
||||
function toMedium(value: string | undefined): Medium | null {
|
||||
return MEDIUM_KEYS.find(medium => MEDIUMS[medium].label === value) ?? null
|
||||
}
|
||||
|
||||
export function toReview(row: ReviewRow): Review {
|
||||
return {
|
||||
id: row.id,
|
||||
date: row.date ?? '',
|
||||
title: (row.title ?? '').trim(),
|
||||
author: row.author_or_director?.trim() || null,
|
||||
text: (row.review ?? '').replace(/ /g, ' ').trim(),
|
||||
medium: toMedium(row.medium?.value)
|
||||
}
|
||||
}
|
||||
|
||||
export async function mediumOptionId(event: H3Event, medium: Medium): Promise<number> {
|
||||
if (!mediumOptions || Date.now() - mediumOptions.fetchedAt > OPTION_CACHE_MS) {
|
||||
const fields = await listFields<Field>(event, 'reviews')
|
||||
const options = fields.find(field => field.name === 'medium')?.select_options ?? []
|
||||
const ids: Partial<Record<Medium, number>> = {}
|
||||
for (const option of options) {
|
||||
const key = toMedium(option.value)
|
||||
if (key) ids[key] = option.id
|
||||
}
|
||||
mediumOptions = { ids, fetchedAt: Date.now() }
|
||||
}
|
||||
|
||||
const id = mediumOptions.ids[medium]
|
||||
if (!id) {
|
||||
throw createError({ statusCode: 500, statusMessage: `The medium field has no "${MEDIUMS[medium].label}" option` })
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user