minor: new nav system (#5)
All checks were successful
ci/woodpecker/push/push Pipeline was successful

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-02-15 04:04:54 +00:00
parent 9261ba92bf
commit 0aab29ea72
27 changed files with 1198 additions and 688 deletions

View File

@@ -1,5 +1,11 @@
export default defineEventHandler((event) => {
const user = event.context.user
if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' })
return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' }
return {
id: user.id,
identifier: user.identifier,
role: user.role,
auth_provider: user.auth_provider ?? 'local',
avatar_url: user.avatar_path ? '/api/me/avatar' : null,
}
})

View File

@@ -0,0 +1,14 @@
import { unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { getDb, getAvatarsDir } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
if (!user.avatar_path) return { ok: true }
const path = join(getAvatarsDir(), user.avatar_path)
await unlink(path).catch(() => {})
const { run } = await getDb()
await run('UPDATE users SET avatar_path = NULL WHERE id = ?', [user.id])
return { ok: true }
})

View File

@@ -0,0 +1,23 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getAvatarsDir } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
const MIME = Object.freeze({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png' })
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
if (!user.avatar_path) throw createError({ statusCode: 404, message: 'No avatar' })
const path = join(getAvatarsDir(), user.avatar_path)
const ext = user.avatar_path.split('.').pop()?.toLowerCase()
const mime = MIME[ext] ?? 'application/octet-stream'
try {
const buf = await readFile(path)
setResponseHeader(event, 'Content-Type', mime)
setResponseHeader(event, 'Cache-Control', 'private, max-age=3600')
return buf
}
catch {
throw createError({ statusCode: 404, message: 'Avatar not found' })
}
})

View File

@@ -0,0 +1,32 @@
import { writeFile, unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { readMultipartFormData } from 'h3'
import { getDb, getAvatarsDir } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
const MAX_SIZE = 2 * 1024 * 1024
const ALLOWED_TYPES = Object.freeze(['image/jpeg', 'image/png'])
const EXT_BY_MIME = Object.freeze({ 'image/jpeg': 'jpg', 'image/png': 'png' })
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const form = await readMultipartFormData(event)
const file = form?.find(f => f.name === 'avatar' && f.data)
if (!file || !file.filename) throw createError({ statusCode: 400, message: 'Missing avatar file' })
if (file.data.length > MAX_SIZE) throw createError({ statusCode: 400, message: 'File too large' })
const mime = file.type ?? ''
if (!ALLOWED_TYPES.includes(mime)) throw createError({ statusCode: 400, message: 'Invalid type; use JPEG or PNG' })
const ext = EXT_BY_MIME[mime] ?? 'jpg'
const filename = `${user.id}.${ext}`
const dir = getAvatarsDir()
const path = join(dir, filename)
await writeFile(path, file.data)
const { run } = await getDb()
const previous = user.avatar_path
await run('UPDATE users SET avatar_path = ? WHERE id = ?', [filename, user.id])
if (previous && previous !== filename) {
const oldPath = join(dir, previous)
await unlink(oldPath).catch(() => {})
}
return { ok: true }
})

View File

@@ -10,10 +10,16 @@ export default defineEventHandler(async (event) => {
const { get } = await getDb()
const session = await get('SELECT user_id, expires_at FROM sessions WHERE id = ?', [sid])
if (!session || new Date(session.expires_at) < new Date()) return
const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [session.user_id])
const user = await get('SELECT id, identifier, role, auth_provider, avatar_path FROM users WHERE id = ?', [session.user_id])
if (user) {
const authProvider = user.auth_provider ?? 'local'
event.context.user = { id: user.id, identifier: user.identifier, role: user.role, auth_provider: authProvider }
event.context.user = {
id: user.id,
identifier: user.identifier,
role: user.role,
auth_provider: authProvider,
avatar_path: user.avatar_path ?? null,
}
}
}
catch {

View File

@@ -1,4 +1,4 @@
import { join } from 'node:path'
import { join, dirname } from 'node:path'
import { mkdirSync, existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { promisify } from 'node:util'
@@ -7,7 +7,7 @@ import { bootstrapAdmin } from './bootstrap.js'
const require = createRequire(import.meta.url)
const sqlite3 = require('sqlite3')
const SCHEMA_VERSION = 2
const SCHEMA_VERSION = 3
const DB_BUSY_TIMEOUT_MS = 5000
let dbInstance = null
@@ -68,6 +68,12 @@ const getDbPath = () => {
return join(dir, 'kestrelos.db')
}
export const getAvatarsDir = () => {
const dir = join(dirname(getDbPath()), 'avatars')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
return dir
}
const getSchemaVersion = async (get) => {
try {
const row = await get('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1')
@@ -99,6 +105,12 @@ const migrateToV2 = async (run, all) => {
}
}
const migrateToV3 = async (run, all) => {
const info = await all('PRAGMA table_info(users)')
if (info.some(c => c.name === 'avatar_path')) return
await run('ALTER TABLE users ADD COLUMN avatar_path TEXT')
}
const runMigrations = async (run, all, get) => {
const version = await getSchemaVersion(get)
if (version >= SCHEMA_VERSION) return
@@ -106,6 +118,10 @@ const runMigrations = async (run, all, get) => {
await migrateToV2(run, all)
await setSchemaVersion(run, 2)
}
if (version < 3) {
await migrateToV3(run, all)
await setSchemaVersion(run, 3)
}
}
const initDb = async (db, run, all, get) => {