Skip to content
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ JWT_SECRET=
# Generate with: openssl rand -hex 16
MEILI_MASTER_KEY=

# Secure cookies (true in production, false in development)
# Secure cookies (default true; set to false only when serving over plain HTTP)
COOKIE_SECURE=

# App port
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ The app reads secrets from `.env` (created by `setup.sh`).
|---|---|---|
| `JWT_SECRET` | Yes | Secret for signing auth tokens (min 32 chars). |
| `MEILI_MASTER_KEY` | Yes | Meilisearch master key. |
| `COOKIE_SECURE` | Yes | Use secure cookies (HTTPS). |
| `COOKIE_SECURE` | No | Use secure cookies (HTTPS). Defaults to `true`; set to `false` when serving over plain HTTP. |
| `PORT` | Yes | Host port for the app. Default `3000`. |
| `DATABASE_PATH` | Yes | SQLite database file path. |
| `FILES_PATH` | Yes | Attachments storage path. |
Expand Down
8 changes: 8 additions & 0 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,11 @@
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background-color: var(--nord-ice); border-radius: 9999px; }
.dark ::-webkit-scrollbar-thumb { background-color: var(--nord-graphite); }

/* Global so it reaches both the PIN input component and the mobile PIN dots */
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-6px); }
40%, 80% { transform: translateX(6px); }
}
.animate-shake { animation: shake 0.5s ease-in-out; }
10 changes: 9 additions & 1 deletion app/components/attachment/ImageGrid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
class="w-full object-cover cursor-zoom-in"
:class="visibleAttachments.length === 1 ? 'max-h-56' : 'h-32'"
loading="lazy"
@error="(e) => (e.target as HTMLImageElement).src = `/api/files/${att.id}`"
@error="(e) => fallbackToOriginal(e, att.id)"
@click.stop="openLightbox(idx)"
>

Expand Down Expand Up @@ -65,6 +65,14 @@
<script setup lang="ts">
import type { Attachment } from '#shared/types/notes'

// Fall back from the thumbnail to the original once; retrying a missing original would loop forever
const fallbackToOriginal = (e: Event, attId: string) => {
const img = e.target as HTMLImageElement
if (img.dataset.fallback) return
img.dataset.fallback = '1'
img.src = `/api/files/${attId}`
}

const props = defineProps<{
attachments: Attachment[]
deletable?: boolean
Expand Down
6 changes: 0 additions & 6 deletions app/components/auth/PinInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,6 @@ defineExpose({ focus })
</script>

<style scoped>
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-6px); }
40%, 80% { transform: translateX(6px); }
}
.animate-shake { animation: shake 0.5s ease-in-out; }
.pin-input {
-webkit-text-security: disc;
line-height: 1;
Expand Down
9 changes: 7 additions & 2 deletions app/components/note/Checklist.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
<!-- Text input -->
<input
:ref="(el) => setInputRef(el, idx)"
v-model="item.text"
:value="item.text"
type="text"
placeholder="List item"
:maxlength="CONSTRAINTS.CHECKLIST_ITEM_MAX_LENGTH"
Expand All @@ -47,7 +47,7 @@
: 'text-nord-obsidian dark:text-nord-ice placeholder-nord-slate dark:placeholder-nord-frost'"
@keydown.enter.prevent="addAfter(idx)"
@keydown.backspace="onBackspace(idx, item, $event)"
@input="$emit('update')"
@input="setText(idx, ($event.target as HTMLInputElement).value)"
>

<!-- Remove button -->
Expand Down Expand Up @@ -107,6 +107,11 @@ const removeItem = (idx: number) => {
emit('update')
}

const setText = (idx: number, text: string) => {
emit('update:modelValue', props.modelValue.map((item, i) => (i === idx ? { ...item, text } : item)))
emit('update')
}

const toggle = (idx: number) => {
emit('update:modelValue', props.modelValue.map((item, i) =>
i === idx ? { ...item, isChecked: !item.isChecked } : item
Expand Down
21 changes: 14 additions & 7 deletions app/components/note/CreateBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const showModal = ref(false)
const initType = ref<'text' | 'checklist'>('text')
const { createNote, uploadAttachment } = useNotes()
const { startUpload, endUpload } = useNoteUploadState()
const { show: showSnackbar } = useSnackbar()

const openModal = (type: 'text' | 'checklist' = 'text') => {
initType.value = type
Expand All @@ -44,13 +45,19 @@ const openModal = (type: 'text' | 'checklist' = 'text') => {
const onSave = async (payload: NewNotePayload) => {
showModal.value = false
const labelIds = [...new Set([...(props.defaultLabelIds ?? []), ...payload.labelIds])]
const note = await createNote({
type: payload.type,
title: payload.title,
content: payload.content,
labelIds,
checklistItems: payload.checklistItems,
})
let note: Note
try {
note = await createNote({
type: payload.type,
title: payload.title,
content: payload.content,
labelIds,
checklistItems: payload.checklistItems,
})
} catch (e) {
showSnackbar(getErrorMessage(e, 'Failed to Create Note'), 'error')
return
}
emit('created', note)
if (payload.pendingFiles.length) {
startUpload(note.id)
Expand Down
25 changes: 18 additions & 7 deletions app/components/note/NoteModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ const onUpdate = (payload: NoteEditorState) => {
if (title.value.trim()) titleError.value = false
}

// After a failed save the next close discards the edits instead of trapping the user in the modal
const saveFailed = ref(false)

const handleClose = async () => {
if (isCurrentlyUploading.value) return
if (props.readonly) {
Expand Down Expand Up @@ -221,13 +224,21 @@ const handleClose = async () => {
showSnackbar('Title is Required', 'error')
return
}
await updateNote(props.note.id, {
type: type.value,
title: title.value,
content: content.value,
labelIds: selectedLabelIds.value,
checklistItems: checklistItems.value.filter((i) => i.text.trim() !== ''),
}).catch(() => {})
if (!saveFailed.value) {
try {
await updateNote(props.note.id, {
type: type.value,
title: title.value,
content: content.value,
labelIds: selectedLabelIds.value,
checklistItems: checklistItems.value.filter((i) => i.text.trim() !== ''),
})
} catch (e) {
saveFailed.value = true
showSnackbar(`${getErrorMessage(e, 'Failed to Save Note')}. Close Again to Discard Changes`, 'error')
return
}
}
}
emit('close')
}
Expand Down
32 changes: 16 additions & 16 deletions app/composables/useNotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,27 +215,28 @@ export const useNotes = (options: { realtime?: boolean } = {}) => {
useSnackbar().show('Note Unarchived')
}

// One request per file keeps each request under the server's upload size limit
const uploadAttachment = async (noteId: string, files: File[]) => {
const form = new FormData()
files.forEach((f) => {
const uploaded: Note['attachments'] = []
for (const f of files) {
const form = new FormData()
const [file, name] = fileWithExt(f)
form.append('files', file, name)
})
let result: { attachments: Note['attachments'] }
try {
result = await apiFetch<{ attachments: Note['attachments'] }>(`/api/notes/${noteId}/attachments`, {
method: 'POST',
body: form,
})
} catch (err) {
useSnackbar().show(getErrorMessage(err, 'Upload Failed'), 'error')
return []
try {
const result = await apiFetch<{ attachments: Note['attachments'] }>(`/api/notes/${noteId}/attachments`, {
method: 'POST',
body: form,
})
uploaded.push(...result.attachments)
} catch (err) {
useSnackbar().show(getErrorMessage(err, 'Upload Failed'), 'error')
}
}
const idx = notes.value.findIndex((n) => n.id === noteId)
if (idx !== -1) {
notes.value[idx]!.attachments.push(...result.attachments)
notes.value[idx]!.attachments.push(...uploaded)
}
return result.attachments
return uploaded
}

const deleteAttachment = async (noteId: string, attId: string) => {
Expand Down Expand Up @@ -277,8 +278,7 @@ export const useNotes = (options: { realtime?: boolean } = {}) => {
}

const emptyTrash = async () => {
const trashed = notes.value.filter((n) => n.isTrashed)
await Promise.all(trashed.map((n) => apiFetch(noteUrl(n.id), { method: 'DELETE' })))
await apiFetch('/api/notes/trash', { method: 'DELETE' })
notes.value = notes.value.filter((n) => !n.isTrashed)
useSnackbar().show('All Trash Permanently Deleted')
}
Expand Down
23 changes: 20 additions & 3 deletions app/error.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
<template>
<div />
<div
v-if="!isUnauthorized"
class="min-h-screen bg-nord-snow dark:bg-nord-storm flex flex-col items-center justify-center gap-3 p-4 text-center"
>
<p class="text-5xl font-bold text-nord-aurora">{{ error?.statusCode ?? 500 }}</p>
<p class="text-nord-slate dark:text-nord-frost">{{ message }}</p>
<button
type="button"
class="mt-4 px-4 py-2 rounded-lg bg-nord-aurora text-nord-storm text-sm font-medium hover:opacity-90 transition-opacity"
@click="clearError({ redirect: '/' })"
>
Back to Notes
</button>
</div>
</template>

<script setup lang="ts">
const error = useError()
const isUnauthorized = computed(() => error.value?.statusCode === 401)
const message = computed(() =>
error.value?.statusCode === 404 ? 'Page Not Found' : 'Something Went Wrong'
)

const redirect = async () => {
await clearError({ redirect: '/login' })
}

// Both SSR and client-side: redirect 401 to login immediately
if (error.value?.statusCode === 401) {
if (isUnauthorized.value) {
await redirect()
} else {
onMounted(async () => {
if (error.value?.statusCode === 401) await redirect()
if (isUnauthorized.value) await redirect()
})
}
</script>
9 changes: 7 additions & 2 deletions app/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ export default defineNuxtRouteMiddleware(async () => {
// useRequestFetch forwards browser cookies during SSR (hard refresh),
// falling back to plain $fetch on client-side navigation.
const apiFetch = useRequestFetch()
const { execute } = useAuthRequest()
await execute(() => apiFetch('/api/auth/me', { credentials: 'include' }))
try {
await apiFetch('/api/auth/me', { credentials: 'include' })
} catch (error) {
// Returning navigateTo makes SSR answer with a redirect instead of rendering a 401 page
if (getErrorStatus(error) === 401) return navigateTo('/login')
throw error
}
})
60 changes: 16 additions & 44 deletions server/api/auth/login.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,64 +2,36 @@ import { getDb, schema } from '../../utils/db'
import { eq } from 'drizzle-orm'
import { setAuthCookie } from '../../utils/auth'
import { verifyOtp } from '../../utils/otp'
import { getLoginWait, recordLoginFailure, recordLoginSuccess } from '../../utils/loginThrottle'

const COOLDOWN_SECONDS = 60
const MAX_ATTEMPTS = 2

// In-memory brute-force protection (single-user app, no persistence needed)
let failedAttempts = 0
let cooldownUntil = 0
function tooManyAttempts(secondsLeft: number) {
return createError({ statusCode: 429, statusMessage: 'Too many attempts', data: { secondsLeft } })
}

export default defineEventHandler(async (event) => {
const now = Date.now()

// Check cooldown
if (cooldownUntil > now) {
const secondsLeft = Math.ceil((cooldownUntil - now) / 1000)
throw createError({
statusCode: 429,
statusMessage: 'Too many attempts',
data: { secondsLeft },
})
}
const clientKey = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'

const db = getDb()
const body = await readBody(event)
const { code } = body as { code: string }
const wait = getLoginWait(clientKey)
if (wait > 0) throw tooManyAttempts(wait)

if (!code || !/^\d{6}$/.test(code)) {
const body = await readBody(event) as { code?: unknown } | null
const code = body?.code
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid OTP Format' })
}

const row = await db.select().from(schema.settings).where(eq(schema.settings.key, 'totp_secret')).get()
const row = await getDb().select().from(schema.settings).where(eq(schema.settings.key, 'totp_secret')).get()
if (!row) {
throw createError({ statusCode: 404, statusMessage: 'OTP Not Configured' })
}

const valid = await verifyOtp(code, row.value)
if (!valid) {
failedAttempts++
if (failedAttempts >= MAX_ATTEMPTS) {
cooldownUntil = now + COOLDOWN_SECONDS * 1000
failedAttempts = 0
throw createError({
statusCode: 429,
statusMessage: 'Too many attempts',
data: { secondsLeft: COOLDOWN_SECONDS },
})
}
throw createError({
statusCode: 401,
statusMessage: 'Invalid OTP',
data: { attemptsLeft: MAX_ATTEMPTS - failedAttempts },
})
if (!(await verifyOtp(code, row.value))) {
const { wait: nextWait, attemptsLeft } = recordLoginFailure(clientKey)
if (nextWait > 0) throw tooManyAttempts(nextWait)
throw createError({ statusCode: 401, statusMessage: 'Invalid OTP', data: { attemptsLeft } })
}

// Successful login — reset counters
failedAttempts = 0
cooldownUntil = 0

recordLoginSuccess(clientKey)
await setAuthCookie(event)

return { success: true }
})
Loading
Loading