├── .dockerignore ├── .gitignore ├── .prettierrc ├── Dockerfile ├── LICENCE.md ├── README.md ├── app.vue ├── assets └── css │ └── main.css ├── components ├── Common │ ├── BaseButton.vue │ ├── BaseCard.vue │ ├── BaseDialog.vue │ ├── BaseInput.vue │ ├── ButtonInput.vue │ ├── CallToAction.vue │ ├── CollapseNotebooksButton.vue │ ├── CopyButton.vue │ ├── DangerAlert.vue │ ├── DateDisplay.vue │ ├── DeleteAction.vue │ ├── Editor.vue │ ├── LoadingIndicator.vue │ ├── SavingIndicator.vue │ ├── SliderInput.vue │ ├── StatsCard.vue │ ├── ThemeButton.vue │ ├── ToggleBox.vue │ ├── ToggleInput.vue │ └── Tooltip.vue ├── DarkModeSwitcher.vue ├── DenseListSwitcher.vue ├── MilkdownEditor.vue ├── Note │ ├── Delete.vue │ ├── NewNote.vue │ ├── NoteName.vue │ ├── RecentNotes.vue │ ├── Rename.vue │ └── Share.vue ├── Notebook │ ├── AllNotebooks.vue │ ├── Delete.vue │ ├── NewNotebook.vue │ ├── NotebookContentItems.vue │ ├── NotebookContents.vue │ ├── RenameNotebook.vue │ └── SidebarNotebooks.vue ├── NotebookManage.vue ├── Search │ ├── CommandPalette.vue │ ├── NoResults.vue │ ├── SearchButton.vue │ └── SearchResults.vue ├── Settings │ ├── BasicSettings.vue │ ├── DeleteSharedNote.vue │ ├── SharedNotes.vue │ └── Warnings.vue ├── SlidingSidebar.vue └── TopBar.vue ├── composables ├── useSearch.ts └── useSidebar.ts ├── compose.yaml ├── drizzle.config.ts ├── eslint.config.mjs ├── layouts ├── Auth.vue ├── Default.vue └── Focus.vue ├── middleware └── auth.global.ts ├── milkdown └── text-sub.ts ├── nuxt.config.ts ├── package.json ├── pages ├── guide.vue ├── index.vue ├── login.vue ├── note │ └── [...note].vue ├── notebook │ └── [...notebook].vue ├── settings.vue └── share │ └── [key].vue ├── pdf.css ├── plugins └── settings.client.ts ├── pnpm-lock.yaml ├── public ├── favicon.ico └── robots.txt ├── screenshot.png ├── server ├── api │ ├── attachment │ │ ├── [file].get.ts │ │ ├── check.get.ts │ │ └── index.post.ts │ ├── auth │ │ ├── login.post.ts │ │ ├── logout.get.ts │ │ └── verify.get.ts │ ├── health.ts │ ├── note │ │ ├── [...path].delete.ts │ │ ├── [...path].get.ts │ │ ├── [...path].patch.ts │ │ ├── [...path].post.ts │ │ ├── [...path].put.ts │ │ └── download │ │ │ ├── [...path].get.ts │ │ │ └── pdf │ │ │ └── [...path].get.ts │ ├── notebook │ │ ├── [...path].delete.ts │ │ ├── [...path].get.ts │ │ ├── [...path].post.ts │ │ └── [...path].put.ts │ ├── notes.get.ts │ ├── search.get.ts │ ├── settings │ │ ├── all.get.ts │ │ ├── index.post.ts │ │ └── shared │ │ │ ├── [key].delete.ts │ │ │ └── index.get.ts │ └── share │ │ ├── [...path].post.ts │ │ └── [key].get.ts ├── db │ ├── migrations │ │ ├── 0000_ambiguous_taskmaster.sql │ │ ├── 0001_confused_blade.sql │ │ ├── 0002_silent_deadpool.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ ├── 0002_snapshot.json │ │ │ └── _journal.json │ └── schema.ts ├── folder.ts ├── key.ts ├── middleware │ └── auth.ts ├── plugins │ └── drizzle.migrate.ts ├── tsconfig.json ├── utils.ts ├── utils │ ├── drizzle.ts │ └── html-gen.ts └── wrappers │ ├── attachment-auth.ts │ ├── attachment.ts │ ├── error.ts │ ├── note.ts │ ├── notebook.ts │ └── search.ts ├── stores ├── auth.ts ├── notebooks.ts └── settings.ts ├── tailwind.config.js ├── tests ├── server │ └── api │ │ ├── attachment.test.ts │ │ ├── health.test.ts │ │ ├── note.test.ts │ │ ├── notebook.test.ts │ │ ├── search.test.ts │ │ └── settings.test.ts └── setup.ts ├── tsconfig.json ├── types ├── notebook.ts ├── result.ts ├── shell-escape.d.ts ├── ui.ts └── upload.ts ├── utils ├── delay.ts ├── downloader.ts ├── file-extension.ts ├── path-joiner.ts └── uploader.ts └── vitest.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | #Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | 26 | # notes dir 27 | notes 28 | 29 | # other 30 | screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | 26 | # notes dir 27 | notes 28 | uploads 29 | config 30 | 31 | # debugging plugin 32 | utils/md-plugins -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "printWidth": 120, 6 | "trailingComma": "none", 7 | "htmlWhitespaceSensitivity": "ignore", 8 | "bracketSameLine": true, 9 | "singleAttributePerLine": false, 10 | "plugins": ["prettier-plugin-tailwindcss"] 11 | } 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | ARG NODE_VERSION=23.11.0 4 | 5 | # Base image for development and build 6 | FROM node:${NODE_VERSION}-slim AS base 7 | 8 | WORKDIR /src 9 | 10 | # Install pnpm globally 11 | RUN npm install -g pnpm 12 | 13 | # Install dependencies and build app 14 | FROM base AS build 15 | 16 | # Copy only lockfile and manifest first to leverage cache 17 | COPY package.json pnpm-lock.yaml ./ 18 | 19 | # Install dependencies (no dev dependencies in production build) 20 | RUN pnpm install --frozen-lockfile 21 | 22 | # Copy source files 23 | COPY . . 24 | 25 | # Build the application 26 | RUN pnpm build 27 | 28 | # Production runtime image 29 | FROM node:${NODE_VERSION}-slim AS runtime 30 | 31 | ENV NODE_ENV=production 32 | WORKDIR /src 33 | 34 | # Required for Puppeteer (headless Chrome) 35 | RUN apt-get update && \ 36 | apt-get install -y --no-install-recommends \ 37 | curl \ 38 | libasound2 \ 39 | libatk1.0-0 \ 40 | libatk-bridge2.0-0 \ 41 | libcairo2 \ 42 | libcups2 \ 43 | libdbus-1-3 \ 44 | libexpat1 \ 45 | libfontconfig1 \ 46 | libgbm1 \ 47 | libgcc-s1 \ 48 | libgconf-2-4 \ 49 | libgdk-pixbuf2.0-0 \ 50 | libglib2.0-0 \ 51 | libgtk-3-0 \ 52 | libnspr4 \ 53 | libnss3 \ 54 | libpango-1.0-0 \ 55 | libpangocairo-1.0-0 \ 56 | libstdc++6 \ 57 | libx11-6 \ 58 | libx11-xcb1 \ 59 | libxcb1 \ 60 | libxcomposite1 \ 61 | libxcursor1 \ 62 | libxdamage1 \ 63 | libxext6 \ 64 | libxfixes3 \ 65 | libxi6 \ 66 | libxrandr2 \ 67 | libxrender1 \ 68 | libxss1 \ 69 | libxtst6 \ 70 | ca-certificates \ 71 | fonts-liberation \ 72 | lsb-release \ 73 | xdg-utils \ 74 | wget && \ 75 | rm -rf /var/lib/apt/lists/* 76 | 77 | # Install Puppeteer Chrome only (no Puppeteer lib) 78 | RUN npx puppeteer browsers install chrome 79 | 80 | # Copy only needed files from build 81 | COPY --from=build /src/.output /src/.output 82 | COPY --from=build /src/server/db/migrations /src/server/db/migrations 83 | 84 | # Expose port 85 | EXPOSE 3000 86 | 87 | # Start the app 88 | CMD ["node", ".output/server/index.mjs"] 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nanote 2 | 3 | A lightweight, self-hosted note-taking application with filesystem-based storage. Built with Nuxt 3, TypeScript, and designed for simplicity and performance. The primary goal of this app is to manage your notes in a manner that is 100% portable. You should be able to manage your notes in terminal, notepad or any other app - there is no database, just folders and files. 4 | 5 | **Auth** : If you don't set the SECRET_KEY environment variable the default secret key is `nanote` though you should set your own key 6 | 7 | ## Screenshot 8 | 9 | ![Screenshot](https://raw.githubusercontent.com/omarmir/nanote/refs/heads/master/screenshot.png 'Nanote screenshot') 10 | 11 | ## Features 12 | 13 | - 📂 **Notebook-based Organization** - Folders as notebooks, markdown files as notes 14 | - 🔍 **Universal Search** - Fast content search across all notes (OS-optimized) 15 | - 📄 **Markdown Support** - Native .md file handling with proper MIME types 16 | - 🔒 **Local Storage** - No databases - uses your existing filesystem 17 | - 🐳 **Docker Ready** - Full container support with sample compose file 18 | - 🔧 **TypeSafe API** - Fully typed REST endpoints with validation 19 | - 🚀 **Performance** - Optimized file operations and platform-specific search 20 | - 📱 **Mobile friendly** - Mobile friendly layout for viewing and editing notes 21 | 22 | ### Custom Remark Directives 23 | 24 | If you type in certain commands in text, they will be handled by the UI: 25 | 26 | - `::file` will create an inline file picker allowing you to upload files (remember to set the upload path in the environment variables) 27 | - `::fileBlock` will create an file block (a larger icon taking up the whole line) picker allowing you to upload files (remember to set the upload path in the environment variables) 28 | - `::today` will show today's date 29 | - `::now` will show todays's date and time 30 | - `::tomorrow` will show tomorrow's date 31 | - `::yesterday` will show yesterday's date 32 | 33 | ### Pending 34 | 35 | - [ ] **Archive** - Archive notes and notebooks 36 | - [ ] **Rollup checklists** - Rollup checklist items from all their notes into its own page for easier task management 37 | - [x] **File upload** - Images done, need one for file 38 | - [ ] **Encryption** - Note/Notebook encryption at rest 39 | - [ ] **Apps** - Mobile/desktop apps (possibly via PWA) 40 | 41 | ## Getting Started 42 | 43 | ### Docker 44 | 45 | You can use the following published image with the compose file 46 | 47 | ```bash 48 | omarmir/nanote 49 | ``` 50 | 51 | OR 52 | 53 | You can clone the repo, build the image and run the compose file. 54 | 55 | ```bash 56 | git clone https://github.com/omarmir/nanote.git 57 | cd nanote 58 | docker build -t nanote . 59 | ``` 60 | 61 | Edit the compose file (specifically the volume mount point and environment variables): 62 | 63 | ```yml 64 | environment: 65 | - NOTES_PATH=/nanote/notes 66 | - UPLOAD_PATH=/nanote/uploads 67 | - CONFIG_PATH=/nanote/config 68 | - SECRET_KEY= 69 | volumes: 70 | - /path/to/local/uploads/nanote/volume:/nanote 71 | ``` 72 | 73 | If these are not set then the app will save files locally within itself. The notes environment variable is where it will save your notes, and uploads is where any attachments are stored. 74 | 75 | ```bash 76 | docker compose -d up 77 | ``` 78 | 79 | ### Prerequisites 80 | 81 | - Node.js 18+ 82 | - PNPM 8+ 83 | - Docker (optional) 84 | 85 | ### Tech stack 86 | 87 | - Nuxt3 and Vue 88 | - Pinia 89 | - Tailwind 3 90 | - Milkdown (as the main editor) 91 | 92 | ### Contributing 93 | 94 | Right now, the place that needs the most help is the home page, it's hard to read so some help there would be appreciated. Open an issue and discuss the issue first. Nanote is distributed under the GNU Affero General Public License. 95 | 96 | ### Local Development 97 | 98 | ```bash 99 | # Clone repository 100 | git clone https://github.com/omarmir/nanote.git 101 | cd nanote 102 | 103 | # Install dependencies 104 | pnpm install 105 | 106 | # Start development server 107 | pnpm run dev 108 | ``` 109 | 110 | ### API 111 | 112 | The nanote server does expose an API and this will be documented better in the future, the app is in VERY early stages so things are still liable to shift. I am now daily driving this for my notes so its not going anywhere and you should expect updates. 113 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 8 | 19 | -------------------------------------------------------------------------------- /assets/css/main.css: -------------------------------------------------------------------------------- 1 | html { 2 | margin: 0; 3 | } 4 | 5 | body { 6 | font-family: Inter, Helvetica, sans-serif; 7 | @apply bg-gray-100 text-gray-800 dark:bg-neutral-900/95 dark:text-gray-400; 8 | } 9 | h1, 10 | h2, 11 | h3, 12 | h4, 13 | h5 { 14 | font-family: Rubik, Helvetica, sans-serif; 15 | } 16 | -------------------------------------------------------------------------------- /components/Common/BaseButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 9 | -------------------------------------------------------------------------------- /components/Common/BaseCard.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /components/Common/BaseDialog.vue: -------------------------------------------------------------------------------- 1 | 58 | 99 | -------------------------------------------------------------------------------- /components/Common/BaseInput.vue: -------------------------------------------------------------------------------- 1 | 7 | 11 | -------------------------------------------------------------------------------- /components/Common/ButtonInput.vue: -------------------------------------------------------------------------------- 1 | 24 | 32 | -------------------------------------------------------------------------------- /components/Common/CallToAction.vue: -------------------------------------------------------------------------------- 1 | 30 | -------------------------------------------------------------------------------- /components/Common/CollapseNotebooksButton.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /components/Common/CopyButton.vue: -------------------------------------------------------------------------------- 1 | 11 | 31 | -------------------------------------------------------------------------------- /components/Common/DangerAlert.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /components/Common/DateDisplay.vue: -------------------------------------------------------------------------------- 1 | 7 | 11 | -------------------------------------------------------------------------------- /components/Common/DeleteAction.vue: -------------------------------------------------------------------------------- 1 | 10 | 29 | -------------------------------------------------------------------------------- /components/Common/LoadingIndicator.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /components/Common/SavingIndicator.vue: -------------------------------------------------------------------------------- 1 | 8 | 13 | -------------------------------------------------------------------------------- /components/Common/SliderInput.vue: -------------------------------------------------------------------------------- 1 | 13 | 17 | -------------------------------------------------------------------------------- /components/Common/StatsCard.vue: -------------------------------------------------------------------------------- 1 | 34 | -------------------------------------------------------------------------------- /components/Common/ThemeButton.vue: -------------------------------------------------------------------------------- 1 | 11 | 22 | -------------------------------------------------------------------------------- /components/Common/ToggleBox.vue: -------------------------------------------------------------------------------- 1 | 11 | 14 | -------------------------------------------------------------------------------- /components/Common/ToggleInput.vue: -------------------------------------------------------------------------------- 1 | 26 | 35 | -------------------------------------------------------------------------------- /components/Common/Tooltip.vue: -------------------------------------------------------------------------------- 1 | 14 | 19 | -------------------------------------------------------------------------------- /components/DarkModeSwitcher.vue: -------------------------------------------------------------------------------- 1 | 9 | 15 | -------------------------------------------------------------------------------- /components/DenseListSwitcher.vue: -------------------------------------------------------------------------------- 1 | 10 | 13 | -------------------------------------------------------------------------------- /components/Note/Delete.vue: -------------------------------------------------------------------------------- 1 | 28 | 54 | -------------------------------------------------------------------------------- /components/Note/NewNote.vue: -------------------------------------------------------------------------------- 1 | 61 | 100 | -------------------------------------------------------------------------------- /components/Note/NoteName.vue: -------------------------------------------------------------------------------- 1 | 55 | 109 | -------------------------------------------------------------------------------- /components/Note/RecentNotes.vue: -------------------------------------------------------------------------------- 1 | 77 | 94 | -------------------------------------------------------------------------------- /components/Note/Rename.vue: -------------------------------------------------------------------------------- 1 | 12 | 36 | -------------------------------------------------------------------------------- /components/Note/Share.vue: -------------------------------------------------------------------------------- 1 | 37 | 66 | -------------------------------------------------------------------------------- /components/Notebook/Delete.vue: -------------------------------------------------------------------------------- 1 | 26 | 50 | -------------------------------------------------------------------------------- /components/Notebook/NewNotebook.vue: -------------------------------------------------------------------------------- 1 | 10 | 31 | -------------------------------------------------------------------------------- /components/Notebook/NotebookContentItems.vue: -------------------------------------------------------------------------------- 1 | 48 | 59 | -------------------------------------------------------------------------------- /components/Notebook/NotebookContents.vue: -------------------------------------------------------------------------------- 1 | 15 | 24 | -------------------------------------------------------------------------------- /components/Notebook/RenameNotebook.vue: -------------------------------------------------------------------------------- 1 | 43 | 77 | -------------------------------------------------------------------------------- /components/Notebook/SidebarNotebooks.vue: -------------------------------------------------------------------------------- 1 | 15 | 18 | -------------------------------------------------------------------------------- /components/NotebookManage.vue: -------------------------------------------------------------------------------- 1 | 8 | 13 | -------------------------------------------------------------------------------- /components/Search/CommandPalette.vue: -------------------------------------------------------------------------------- 1 | 50 | 76 | -------------------------------------------------------------------------------- /components/Search/NoResults.vue: -------------------------------------------------------------------------------- 1 | 20 | 23 | -------------------------------------------------------------------------------- /components/Search/SearchButton.vue: -------------------------------------------------------------------------------- 1 | 16 | 19 | -------------------------------------------------------------------------------- /components/Search/SearchResults.vue: -------------------------------------------------------------------------------- 1 | 19 | 29 | -------------------------------------------------------------------------------- /components/Settings/BasicSettings.vue: -------------------------------------------------------------------------------- 1 | 33 | 36 | -------------------------------------------------------------------------------- /components/Settings/DeleteSharedNote.vue: -------------------------------------------------------------------------------- 1 | 32 | 53 | -------------------------------------------------------------------------------- /components/Settings/SharedNotes.vue: -------------------------------------------------------------------------------- 1 | 49 | 59 | -------------------------------------------------------------------------------- /components/Settings/Warnings.vue: -------------------------------------------------------------------------------- 1 | 16 | 19 | -------------------------------------------------------------------------------- /components/TopBar.vue: -------------------------------------------------------------------------------- 1 | 21 | 24 | -------------------------------------------------------------------------------- /composables/useSearch.ts: -------------------------------------------------------------------------------- 1 | import { useDebounce } from '@vueuse/core' 2 | import { ref } from 'vue' 3 | import type { Ref } from 'vue' 4 | import type { SearchResult } from '~/types/notebook' 5 | 6 | export function useSearch() { 7 | const search: Ref = ref(null) 8 | const query: Ref<{ q: string | null }> = ref({ q: null }) 9 | const debounced = useDebounce(search, 300) 10 | 11 | const { 12 | data: results, 13 | error, 14 | status, 15 | clear 16 | } = useFetch('/api/search', { 17 | immediate: false, 18 | lazy: true, 19 | query, 20 | transform: (searchRes) => { 21 | return searchRes.map((res) => { 22 | return { 23 | ...res, 24 | snippet: stripMD(res.snippet) 25 | } 26 | }) 27 | } 28 | }) 29 | 30 | watch(debounced, () => { 31 | if (debounced.value && debounced.value?.length > 0) { 32 | query.value = { q: debounced.value } 33 | } else { 34 | clear() 35 | } 36 | }) 37 | 38 | const noResults = computed(() => status.value === 'success' && (results.value?.length === 0 || !results.value)) 39 | 40 | const clearSearch = () => { 41 | search.value = null 42 | clear() 43 | } 44 | 45 | const stripMD = (markdown: string): string => { 46 | return ( 47 | markdown 48 | // Decode HTML entities (e.g., -> space) 49 | .replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => String.fromCharCode(Number.parseInt(hex, 16))) 50 | .replace(/ /g, ' ') // Handle non-breaking spaces separately 51 | // Remove HTML tags 52 | .replace(/<[^>]+(>|$)/g, '') 53 | // Remove headings 54 | .replace(/^#{1,6}\s*/gm, '') 55 | // Remove bold & italic (**, __, *, _) 56 | .replace(/(\*\*|__)(.*?)\1/g, '$2') 57 | .replace(/(\*|_)(.*?)\1/g, '$2') 58 | // Remove inline code (`code`) 59 | .replace(/`([^`]+)`/g, '$1') 60 | // Remove code blocks (triple backticks) 61 | .replace(/```[\s\S]*?```/g, '') 62 | // Remove blockquotes 63 | .replace(/^>\s?/gm, '') 64 | // Remove strikethroughs 65 | .replace(/~~(.*?)~~/g, '$1') 66 | // Remove links but keep text [text](url) 67 | .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') 68 | // Remove images ![alt](url) 69 | .replace(/!\[.*?\]\(.*?\)/g, '') 70 | // Remove unordered lists (-, *, +) 71 | .replace(/^\s*[-*+]\s+/gm, '') 72 | // Remove ordered lists (numbers) 73 | .replace(/^\s*\d+\.\s+/gm, '') 74 | // Remove horizontal rules (---, ***) 75 | .replace(/^\s*(-{3,}|\*{3,})\s*$/gm, '') 76 | // Remove extra whitespace 77 | .replace(/\s+/g, ' ') 78 | .trim() 79 | ) 80 | } 81 | 82 | return { 83 | search, 84 | clearSearch, 85 | stripMD, 86 | error, 87 | status, 88 | results, 89 | noResults, 90 | debounced 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /composables/useSidebar.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | import type { Ref } from 'vue' 3 | 4 | const isSideMenuOpen: Ref = ref(false) 5 | 6 | export function useSidebar() { 7 | const toggleSideMenu = () => { 8 | isSideMenuOpen.value = !isSideMenuOpen.value 9 | } 10 | 11 | const outsideClick = () => { 12 | if (isSideMenuOpen.value) { 13 | isSideMenuOpen.value = false 14 | isSidebarOpen.value = isSideMenuOpen.value 15 | } 16 | } 17 | 18 | const isSidebarOpen: Ref = isSideMenuOpen 19 | 20 | return { 21 | toggleSideMenu, 22 | isSidebarOpen, 23 | outsideClick 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | nanote: 4 | image: omarmir/nanote:latest 5 | ports: 6 | - '3030:3000' 7 | environment: 8 | - NOTES_PATH=/nanote/notes 9 | - UPLOAD_PATH=/nanote/uploads 10 | - CONFIG_PATH=/nanote/config 11 | - SECRET_KEY= 12 | volumes: 13 | - /path/to/local/uploads/nanote/volume:/nanote 14 | healthcheck: 15 | test: ['CMD', 'curl', '-f', 'http://localhost:3000/api/health'] 16 | interval: 30s # Check every 30 seconds 17 | timeout: 10s # Fail if no response in 10s 18 | retries: 3 # Mark as unhealthy after 3 failed checks 19 | start_period: 30s # Wait 10s before starting checks 20 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'drizzle-kit' 2 | import { dbPath } from './server/folder' 3 | export default defineConfig({ 4 | dialect: 'sqlite', 5 | schema: './server/db/schema.ts', 6 | out: './server/db/migrations', 7 | dbCredentials: { 8 | url: dbPath! 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { createConfigForNuxt } from '@nuxt/eslint-config/flat' 2 | import eslintPluginTailwind from 'eslint-plugin-tailwindcss' 3 | 4 | export default createConfigForNuxt({ 5 | features: { 6 | tooling: true, 7 | // Enable TypeScript support 8 | typescript: true 9 | }, 10 | // Add TypeScript-specific configuration 11 | typescript: { 12 | parserOptions: { 13 | parser: '@typescript-eslint/parser', 14 | sourceType: 'module', 15 | extraFileExtensions: ['.vue'] 16 | } 17 | }, 18 | // Add custom rules and plugins 19 | rules: { 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | 'tailwindcss/no-custom-classname': 'warn', 22 | 'vue/multi-word-component-names': ['error', { ignores: [] }] 23 | }, 24 | plugins: { 25 | tailwindcss: eslintPluginTailwind 26 | } 27 | }).override('nuxt/vue/rules', { 28 | rules: { 29 | 'vue/require-default-prop': 'off', 30 | 'vue/html-self-closing': 'off', 31 | 'vue/first-attribute-linebreak': 'off' 32 | } 33 | }) 34 | -------------------------------------------------------------------------------- /layouts/Auth.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /layouts/Default.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | -------------------------------------------------------------------------------- /layouts/Focus.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /middleware/auth.global.ts: -------------------------------------------------------------------------------- 1 | import { useAuthStore } from '~/stores/auth' 2 | 3 | export default defineNuxtRouteMiddleware(async (to, _from) => { 4 | if (to.name !== 'share-key' && to.name !== 'login') { 5 | const store = useAuthStore() 6 | const isLoggedIn = await store.checkAuth() 7 | if (!isLoggedIn) { 8 | return navigateTo('/login') 9 | } 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /milkdown/text-sub.ts: -------------------------------------------------------------------------------- 1 | import type { MilkdownPlugin } from '@milkdown/kit/ctx' 2 | import { $inputRule } from '@milkdown/kit/utils' 3 | import { InputRule } from '@milkdown/prose/inputrules' 4 | export const today = /::today/ 5 | export const now = /::now/ 6 | export const timeNow = /::time/ 7 | export const yesterday = /::yesterday/ 8 | export const tomorrow = /::tomorrow/ 9 | 10 | export const todayInputRule = $inputRule( 11 | (_ctx) => 12 | new InputRule(today, (state, _match, start, end) => { 13 | const currentDate = new Date().toLocaleDateString() 14 | const { tr } = state 15 | tr.deleteRange(start, end) 16 | tr.insertText(currentDate) 17 | return tr 18 | }) 19 | ) 20 | 21 | export const nowInputRule = $inputRule( 22 | (_ctx) => 23 | new InputRule(now, (state, _match, start, end) => { 24 | const currentDate = new Date().toLocaleString() 25 | const { tr } = state 26 | tr.deleteRange(start, end) 27 | tr.insertText(currentDate) 28 | return tr 29 | }) 30 | ) 31 | export const timeNowInputRule = $inputRule( 32 | (_ctx) => 33 | new InputRule(timeNow, (state, _match, start, end) => { 34 | const currentDate = new Date().toLocaleTimeString() 35 | const { tr } = state 36 | tr.deleteRange(start, end) 37 | tr.insertText(currentDate) 38 | return tr 39 | }) 40 | ) 41 | export const yesterdayInputRule = $inputRule( 42 | (_ctx) => 43 | new InputRule(yesterday, (state, _match, start, end) => { 44 | const date = new Date() 45 | date.setDate(date.getDate() - 1) 46 | const currentDate = date.toLocaleDateString() 47 | const { tr } = state 48 | tr.deleteRange(start, end) 49 | tr.insertText(currentDate) 50 | return tr 51 | }) 52 | ) 53 | 54 | export const tomorrowInputRule = $inputRule( 55 | (_ctx) => 56 | new InputRule(tomorrow, (state, _match, start, end) => { 57 | const date = new Date() 58 | date.setDate(date.getDate() + 1) 59 | const currentDate = date.toLocaleDateString() 60 | const { tr } = state 61 | tr.deleteRange(start, end) 62 | tr.insertText(currentDate) 63 | return tr 64 | }) 65 | ) 66 | 67 | export const dateTimeTextSubs: MilkdownPlugin[] = [ 68 | todayInputRule, 69 | nowInputRule, 70 | timeNowInputRule, 71 | yesterdayInputRule, 72 | tomorrowInputRule 73 | ].flat() 74 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | 3 | export default defineNuxtConfig({ 4 | app: { 5 | head: { 6 | title: 'Nanote', // default fallback title 7 | htmlAttrs: { 8 | lang: 'en' 9 | }, 10 | link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }] 11 | } 12 | }, 13 | compatibilityDate: '2024-11-01', 14 | ssr: false, 15 | devtools: { 16 | enabled: true, 17 | timeline: { 18 | enabled: true 19 | } 20 | }, 21 | css: ['~/assets/css/main.css'], 22 | modules: [ 23 | '@nuxt/fonts', 24 | '@nuxt/eslint', 25 | '@nuxtjs/eslint-module', 26 | '@nuxtjs/tailwindcss', 27 | '@pinia/nuxt', 28 | '@nuxt/test-utils/module', 29 | '@nuxt/icon', 30 | 'nuxt-codemirror' 31 | ], 32 | tailwindcss: { 33 | config: { 34 | content: { 35 | files: ['./components/**/*.{vue,js,ts}', './layouts/**/*.vue', './pages/**/*.vue', '!./node_modules'] 36 | } 37 | } 38 | }, 39 | icon: { 40 | size: '20px', 41 | class: 'icon', 42 | mode: 'svg', 43 | cssLayer: 'base', 44 | clientBundle: { 45 | scan: true 46 | } 47 | }, 48 | fonts: { 49 | families: [ 50 | // do not resolve this font with any provider from `@nuxt/fonts` 51 | { name: 'Rubik', provider: 'google', global: true }, 52 | // only resolve this font with the `google` provider 53 | { name: 'Inter', provider: 'google', global: true }, 54 | // specify specific font data - this will bypass any providers 55 | { name: 'JetBrains Mono', provider: 'google', global: true } 56 | ] 57 | }, 58 | routeRules: { 59 | '/api/notebook': { redirect: '/api/notebook/*' } 60 | }, 61 | vite: { 62 | server: { 63 | watch: { 64 | usePolling: false // on Linux/macOS you usually don’t need polling 65 | } 66 | }, 67 | optimizeDeps: { 68 | // pre-bundle common deps so they’re not re-scanned each time 69 | include: ['vue', 'vue-router', 'pinia'] 70 | } 71 | } 72 | }) 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nanote", 3 | "description": "Lightweight, self-hosted note-taking application with filesystem-based storage so you maintain 100% portability of your notes.", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "nuxt build", 8 | "test": "vitest run", 9 | "dev": "nuxt dev", 10 | "generate": "nuxt generate", 11 | "preview": "nuxt preview", 12 | "postinstall": "nuxt prepare", 13 | "lint": "pnpm lint:eslint && pnpm lint:prettier", 14 | "lint:eslint": "eslint .", 15 | "lint:prettier": "prettier . --check", 16 | "lintfix": "eslint . --fix && prettier --write --list-different .", 17 | "config-check": "npx @eslint/config-inspector@latest", 18 | "db:generate": "drizzle-kit generate" 19 | }, 20 | "dependencies": { 21 | "@codemirror/state": "^6.5.2", 22 | "@codemirror/view": "^6.36.8", 23 | "@libsql/client": "^0.15.4", 24 | "@milkdown/core": "^7.10.1", 25 | "@milkdown/crepe": "^7.10.1", 26 | "@milkdown/exception": "^7.10.1", 27 | "@milkdown/kit": "^7.10.1", 28 | "@milkdown/plugin-emoji": "^7.10.1", 29 | "@milkdown/plugin-slash": "^7.10.1", 30 | "@milkdown/prose": "^7.10.1", 31 | "@milkdown/theme-nord": "^7.10.1", 32 | "@milkdown/transformer": "^7.10.1", 33 | "@milkdown/utils": "^7.10.1", 34 | "@milkdown/vue": "^7.10.1", 35 | "@nuxt/fonts": "0.11.0", 36 | "@nuxt/icon": "1.12.0", 37 | "@nuxt/kit": "^3.17.3", 38 | "@nuxtjs/tailwindcss": "6.13.1", 39 | "@pinia/nuxt": "0.11.0", 40 | "@vueuse/core": "13.0.0", 41 | "@vueuse/integrations": "13.0.0", 42 | "content-disposition": "^0.5.4", 43 | "drizzle-orm": "^0.43.1", 44 | "fast-glob": "^3.3.3", 45 | "focus-trap": "7.6.4", 46 | "jsonwebtoken": "^9.0.2", 47 | "material-file-icons": "^2.4.0", 48 | "milkdown-plugin-file": "0.2.1", 49 | "mime": "^4.0.7", 50 | "nanoid": "^5.1.5", 51 | "nuxt": "^3.17.2", 52 | "nuxt-codemirror": "0.0.16", 53 | "pinia": "3.0.2", 54 | "puppeteer": "^24.10.0", 55 | "remark-html": "^16.0.1", 56 | "remark-parse": "^11.0.0", 57 | "shell-escape": "^0.2.0", 58 | "unified": "^11.0.5", 59 | "view": "link:@milkdown/prose/view", 60 | "vue": "latest", 61 | "vue-router": "latest" 62 | }, 63 | "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0", 64 | "devDependencies": { 65 | "@iconify-json/gg": "^1.2.2", 66 | "@iconify-json/lucide": "^1.2.41", 67 | "@iconify-json/marketeq": "^1.2.2", 68 | "@nuxt/eslint": "^1.3.0", 69 | "@nuxt/eslint-config": "^1.3.0", 70 | "@nuxt/test-utils": "^3.18.0", 71 | "@nuxtjs/eslint-module": "^4.1.0", 72 | "@types/content-disposition": "^0.5.8", 73 | "@types/jsonwebtoken": "^9.0.9", 74 | "@types/shell-escape": "^0.2.3", 75 | "@typescript-eslint/eslint-plugin": "^8.32.0", 76 | "@typescript-eslint/parser": "^8.32.0", 77 | "@vue/test-utils": "^2.4.6", 78 | "drizzle-kit": "^0.31.1", 79 | "eslint": "^9.26.0", 80 | "eslint-config-prettier": "^10.1.5", 81 | "eslint-plugin-nuxt": "^4.0.0", 82 | "eslint-plugin-prettier": "^5.4.0", 83 | "eslint-plugin-tailwindcss": "3.18.0", 84 | "happy-dom": "^17.4.6", 85 | "playwright-core": "^1.52.0", 86 | "prettier": "^3.5.3", 87 | "prettier-plugin-tailwindcss": "^0.6.11", 88 | "vitest": "^3.1.3" 89 | }, 90 | "pnpm": { 91 | "overrides": { 92 | "mdast-util-gfm-autolink-literal": "github:omarmir/mdast-util-gfm-autolink-literal", 93 | "milkdown-plugin-file": "github:omarmir/milkdown-plugin-file" 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /pages/guide.vue: -------------------------------------------------------------------------------- 1 | 66 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /pages/login.vue: -------------------------------------------------------------------------------- 1 | 31 | 43 | -------------------------------------------------------------------------------- /pages/note/[...note].vue: -------------------------------------------------------------------------------- 1 | 18 | 22 | -------------------------------------------------------------------------------- /pages/notebook/[...notebook].vue: -------------------------------------------------------------------------------- 1 | 94 | 103 | -------------------------------------------------------------------------------- /pages/settings.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | -------------------------------------------------------------------------------- /pages/share/[key].vue: -------------------------------------------------------------------------------- 1 | 47 | 122 | -------------------------------------------------------------------------------- /pdf.css: -------------------------------------------------------------------------------- 1 | @media print { 2 | @page { 3 | margin: 1cm 1.5cm; /* top-bottom, left-right */ 4 | } 5 | 6 | body { 7 | /* Optional: avoid extra spacing issues */ 8 | margin: 0; 9 | } 10 | } 11 | 12 | html { 13 | font-family: sans-serif; 14 | } 15 | 16 | img { 17 | width: 100%; 18 | } 19 | -------------------------------------------------------------------------------- /plugins/settings.client.ts: -------------------------------------------------------------------------------- 1 | import type { Settings } from '~/server/utils/drizzle' 2 | import type { Result } from '~/types/result' 3 | 4 | export default defineNuxtPlugin(async (_nuxtApp) => { 5 | const { data: settings, error } = await useFetch('/api/settings/all', { 6 | immediate: localStorage.getItem('isLoggedIn') === 'true', 7 | lazy: false, 8 | transform: (data: Result | null) => { 9 | if (!data) return new Map() 10 | if (data.success) { 11 | // Convert array to map: { [setting]: value } 12 | const settingsMap = new Map() 13 | for (const item of data.data) { 14 | settingsMap.set(item.setting, item.value) 15 | } 16 | return settingsMap 17 | } else { 18 | return new Map() 19 | } 20 | } 21 | }) 22 | 23 | return { 24 | provide: { 25 | settings: { 26 | data: settings.value ?? new Map(), 27 | error: error.value?.statusMessage 28 | } 29 | } 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/omarmir/nanote/28b0182278e535cb6344935b3d4c377bf2ba9d15/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/omarmir/nanote/28b0182278e535cb6344935b3d4c377bf2ba9d15/screenshot.png -------------------------------------------------------------------------------- /server/api/attachment/[file].get.ts: -------------------------------------------------------------------------------- 1 | import type { ReadStream } from 'node:fs' 2 | import { createReadStream, existsSync } from 'node:fs' 3 | import { resolve } from 'node:path' 4 | import { uploadPath } from '~/server/folder' 5 | import mime from 'mime' 6 | import { defineEventHandlerWithAttachmentAuthError } from '~/server/wrappers/attachment-auth' 7 | 8 | export default defineEventHandlerWithAttachmentAuthError(async (event): Promise => { 9 | const file = event.context.params?.file 10 | 11 | if (!file) { 12 | throw createError({ 13 | statusCode: 400, 14 | statusMessage: 'Bad Request', 15 | message: 'No file specified' 16 | }) 17 | } 18 | // Construct the path to your file. Adjust the base folder as needed. 19 | const filePath = resolve(uploadPath, 'attachments', decodeURIComponent(file)) 20 | 21 | // Check if the file exists 22 | if (!existsSync(filePath)) { 23 | throw createError({ 24 | statusCode: 404, 25 | statusMessage: 'File not found' 26 | }) 27 | } 28 | 29 | const mimeType = mime.getType(filePath) || 'application/octet-stream' 30 | 31 | setHeaders(event, { 32 | 'Content-Type': mimeType, 33 | 'Content-Disposition': `attachment; filename="${file}"`, 34 | 'Cache-Control': 'no-cache' 35 | }) 36 | 37 | // Stream the file back as the response 38 | return createReadStream(filePath) 39 | }) 40 | -------------------------------------------------------------------------------- /server/api/attachment/check.get.ts: -------------------------------------------------------------------------------- 1 | import { access, constants } from 'node:fs/promises' 2 | import { resolve } from 'node:path' 3 | import { uploadPath } from '~/server/folder' 4 | import { defineEventHandlerWithAttachmentAuthError } from '~/server/wrappers/attachment-auth' 5 | 6 | // This route is used by the milkdown plugin to see if the attachment is accessible 7 | export default defineEventHandlerWithAttachmentAuthError(async (event) => { 8 | const query = getQuery(event) 9 | const fileURL = query.url as string 10 | 11 | if (!fileURL) { 12 | throw createError({ 13 | statusCode: 400, 14 | statusMessage: 'Bad Request', 15 | message: 'No file specified in query parameters' 16 | }) 17 | } 18 | 19 | const fileName = fileURL.split('/').at(-1) 20 | 21 | if (!fileName) { 22 | throw createError({ 23 | statusCode: 400, 24 | statusMessage: 'Bad Request', 25 | message: 'Unable to get file name from the query parameters' 26 | }) 27 | } 28 | 29 | const filePath = resolve(uploadPath, 'attachments', decodeURIComponent(fileName)) 30 | 31 | await access(filePath, constants.R_OK | constants.W_OK) // Make sure its readable and writable 32 | 33 | return { 34 | exists: true 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /server/api/attachment/index.post.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid' 2 | import { writeFile, mkdir, constants } from 'node:fs/promises' 3 | import path from 'node:path' 4 | import { access, existsSync } from 'node:fs' 5 | import { uploadPath } from '~/server/folder' 6 | import type { MultiPartData } from '~/types/upload' 7 | import { defineEventHandlerWithAttachmentAuthError } from '~/server/wrappers/attachment-auth' 8 | // import { waitforme } from '~/server/utils' 9 | 10 | export default defineEventHandlerWithAttachmentAuthError(async (event) => { 11 | const formData = await readMultipartFormData(event) 12 | if (!formData) { 13 | throw createError({ 14 | statusCode: 400, 15 | statusMessage: 'Bad Request', 16 | message: 'Missing form data' 17 | }) 18 | } 19 | 20 | // await waitforme(5000) 21 | 22 | const { fileEntry, pathEntry } = formData.reduce( 23 | (acc, entry) => { 24 | if (entry.name === 'file') acc.fileEntry = entry 25 | if (entry.name === 'path') acc.pathEntry = entry 26 | return acc 27 | }, 28 | {} as { fileEntry?: MultiPartData; pathEntry?: MultiPartData } 29 | ) 30 | 31 | if (!fileEntry?.data || !pathEntry?.data) { 32 | throw createError({ 33 | statusCode: 400, 34 | statusMessage: 'Bad Request', 35 | message: 'No file uploaded or note path unspecified' 36 | }) 37 | } 38 | 39 | const attachBasePath = path.join(uploadPath, 'attachments') 40 | if (!existsSync(attachBasePath)) { 41 | await mkdir(attachBasePath, { recursive: true }) 42 | } 43 | 44 | access(attachBasePath, constants.R_OK | constants.W_OK, (err) => { 45 | if (err) { 46 | console.log(err) 47 | throw createError({ 48 | statusCode: 401, 49 | statusMessage: 'Unauthorized', 50 | message: 'Attachment folder is not read/write accessible.' 51 | }) 52 | } 53 | }) 54 | 55 | const nanoid = customAlphabet('abcdefghijklmop') 56 | const id = nanoid() 57 | const fileName = `${id}_${fileEntry.filename?.replace(/[\\/:*?"<>]/g, '')}` 58 | 59 | if (fileName.length > 255) { 60 | throw createError({ 61 | statusCode: 400, 62 | statusMessage: 'Bad Request', 63 | message: `Filename ${fileName} will exceed allowed length of 255 characters.` 64 | }) 65 | } 66 | 67 | const attachPath = path.join(attachBasePath, fileName) 68 | 69 | const isWindows = process.platform === 'win32' 70 | const maxPathLength = isWindows ? 259 : 4095 // Same limits as folder creation 71 | if (attachPath.length > maxPathLength) { 72 | throw createError({ 73 | statusCode: 400, 74 | statusMessage: 'Bad Request', 75 | message: 'Attachment file path is going to exceed maximum path length for your operating system.' 76 | }) 77 | } 78 | 79 | await writeFile(attachPath, fileEntry.data) 80 | return `/api/attachment/${fileName}` 81 | }) 82 | -------------------------------------------------------------------------------- /server/api/auth/login.post.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import SECRET_KEY from '~/server/key' 3 | import { defineEventHandlerWithError } from '~/server/wrappers/error' 4 | 5 | export default defineEventHandlerWithError(async (event) => { 6 | const body = await readBody(event) 7 | const { key } = body 8 | 9 | // Example user authentication (replace with DB lookup) 10 | if (key !== SECRET_KEY) { 11 | throw createError({ 12 | statusCode: 401, 13 | statusMessage: 'Unauthorized', 14 | message: 'Secret key does not match.' 15 | }) 16 | } 17 | 18 | // Create JWT token 19 | const token = jwt.sign({ app: 'nanote' }, SECRET_KEY, { expiresIn: '7d', audience: 'authorized' }) 20 | 21 | setCookie(event, 'token', token, { 22 | httpOnly: true, 23 | sameSite: 'strict', 24 | maxAge: 3600 * 24 * 7, // 7 days 25 | path: '/' 26 | }) 27 | 28 | return { 29 | success: true, 30 | token 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /server/api/auth/logout.get.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandlerWithError } from '~/server/wrappers/error' 2 | 3 | export default defineEventHandlerWithError(async (event): Promise => { 4 | deleteCookie(event, 'token') 5 | }) 6 | -------------------------------------------------------------------------------- /server/api/auth/verify.get.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import SECRET_KEY from '~/server/key' 3 | import { defineEventHandlerWithError } from '~/server/wrappers/error' 4 | import type { Result } from '~/types/result' 5 | 6 | export default defineEventHandlerWithError(async (event): Promise> => { 7 | const cookie = getCookie(event, 'token') 8 | 9 | if (!cookie) { 10 | return { 11 | success: false, 12 | message: 'Not cookie.' 13 | } satisfies Result 14 | } 15 | 16 | try { 17 | jwt.verify(cookie, SECRET_KEY, { audience: 'authorized' }) 18 | return { 19 | success: true, 20 | data: true 21 | } satisfies Result 22 | } catch (err) { 23 | console.log(err) 24 | return { 25 | success: false, 26 | message: 'Verification failed.' 27 | } satisfies Result 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /server/api/health.ts: -------------------------------------------------------------------------------- 1 | import SECRET_KEY from '~/server/key' 2 | import { envNotesPath, envUploadsPath, envConfigPath } from '~/server/folder' 3 | import { defineEventHandlerWithError } from '../wrappers/error' 4 | 5 | type Health = { 6 | status: 'OK' 7 | message: 'Service is running' 8 | warnings: string[] 9 | } 10 | 11 | export default defineEventHandlerWithError(async (_event): Promise => { 12 | const warnings = [] 13 | 14 | if (SECRET_KEY === 'nanote') warnings.push('Secret key should be changed from the default.') 15 | if (!envNotesPath) 16 | if (!envNotesPath) warnings.push('Storage location is not set, this could result in loss of notes.') 17 | if (!envUploadsPath) 18 | if (!envUploadsPath) warnings.push('Uploads location is not set, this could result in loss of uploads.') 19 | 20 | if (!envConfigPath) 21 | warnings.push('Config location is not set, this could result in loss of settings and shared notes.') 22 | 23 | return { 24 | status: 'OK', 25 | message: 'Service is running', 26 | warnings 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /server/api/note/[...path].delete.ts: -------------------------------------------------------------------------------- 1 | import { unlink } from 'node:fs/promises' 2 | import { defineEventHandlerWithAttachmentNotebookNote } from '~/server/wrappers/attachment' 3 | 4 | import type { DeleteNote } from '~/types/notebook' 5 | 6 | /** 7 | * Delete note 8 | */ 9 | export default defineEventHandlerWithAttachmentNotebookNote( 10 | async ( 11 | event, 12 | notebook, 13 | note, 14 | fullPath, 15 | _markAttachmentForDeletionIfNeeded, 16 | deleteAllAttachments 17 | ): Promise => { 18 | // Read file contents and stats 19 | await unlink(fullPath) 20 | 21 | await deleteAllAttachments() 22 | 23 | return { 24 | notebook: notebook, 25 | name: note, 26 | deleted: true, 27 | timestamp: new Date().toISOString() 28 | } satisfies DeleteNote 29 | } 30 | ) 31 | -------------------------------------------------------------------------------- /server/api/note/[...path].get.ts: -------------------------------------------------------------------------------- 1 | import { stat } from 'node:fs/promises' 2 | import { defineEventHandlerWithNotebookAndNote } from '~/server/wrappers/note' 3 | import type { Note } from '~/types/notebook' 4 | 5 | /** 6 | * Get note info 7 | */ 8 | export default defineEventHandlerWithNotebookAndNote( 9 | async (_event, notebooks, note, fullPath, _notebookPath, isMarkdown): Promise => { 10 | // Read file contents and stats 11 | const stats = await stat(fullPath) 12 | const createdAtTime = stats.birthtime.getTime() !== 0 ? stats.birthtime : stats.ctime 13 | 14 | return { 15 | name: note, 16 | notebook: notebooks, 17 | createdAt: createdAtTime.toISOString(), 18 | updatedAt: stats.mtime.toISOString(), 19 | size: stats.size, // Optional: Remove if not needed 20 | isMarkdown 21 | } satisfies Note 22 | } 23 | ) 24 | -------------------------------------------------------------------------------- /server/api/note/[...path].patch.ts: -------------------------------------------------------------------------------- 1 | import { writeFile, stat } from 'node:fs/promises' 2 | import { readMultipartFormData } from 'h3' 3 | import { defineEventHandlerWithAttachmentNotebookNote } from '~/server/wrappers/attachment' 4 | import type { NoteResponse } from '~/types/notebook' 5 | 6 | /** 7 | * Update note 8 | */ 9 | 10 | export default defineEventHandlerWithAttachmentNotebookNote( 11 | async (event, notebook, note, fullPath, markAttachmentForDeletionIfNeeded): Promise => { 12 | // Parse form data 13 | const formData = await readMultipartFormData(event) 14 | if (!formData) { 15 | throw createError({ 16 | statusCode: 400, 17 | statusMessage: 'Bad Request', 18 | message: 'Missing form data' 19 | }) 20 | } 21 | 22 | // Find file in form data 23 | const fileEntry = formData.find((entry) => entry.name === 'file') 24 | if (!fileEntry?.data) { 25 | throw createError({ 26 | statusCode: 400, 27 | statusMessage: 'Bad Request', 28 | message: 'No file uploaded' 29 | }) 30 | } 31 | 32 | // Get original stats first to preserve creation date 33 | const originalStats = await stat(fullPath) 34 | const originalStatsCreatedAtTime = 35 | originalStats.birthtime.getTime() !== 0 ? originalStats.birthtime : originalStats.ctime 36 | 37 | // Overwrite file content 38 | await writeFile(fullPath, fileEntry.data) 39 | 40 | // remove any attachments that are gone 41 | await markAttachmentForDeletionIfNeeded(fileEntry.data) 42 | 43 | // Get new stats after update 44 | const newStats = await stat(fullPath) 45 | 46 | return { 47 | notebook, 48 | note, 49 | path: fullPath, 50 | createdAt: originalStatsCreatedAtTime.toISOString(), 51 | updatedAt: newStats.mtime.toISOString(), 52 | size: newStats.size, 53 | originalFilename: fileEntry.filename || 'unknown' 54 | } satisfies NoteResponse 55 | } 56 | ) 57 | -------------------------------------------------------------------------------- /server/api/note/[...path].post.ts: -------------------------------------------------------------------------------- 1 | import { writeFile, stat } from 'node:fs/promises' 2 | import { readMultipartFormData } from 'h3' 3 | import { Buffer } from 'node:buffer' 4 | 5 | import { defineEventHandlerWithNotebookAndNote } from '~/server/wrappers/note' 6 | import type { Note } from '~/types/notebook' 7 | import { checkIfPathExists } from '~/server/utils' 8 | 9 | /** 10 | * Add note 11 | */ 12 | export default defineEventHandlerWithNotebookAndNote( 13 | async (event, notebook, note, fullPath): Promise => { 14 | const body = await readBody(event) 15 | const isManualFile = body.isManualFile ?? false 16 | 17 | const mdPath = isManualFile ? fullPath : fullPath.concat('.md') 18 | let fileContent = Buffer.from('') 19 | 20 | // Parse form data if available 21 | const formData = await readMultipartFormData(event) 22 | if (formData) { 23 | const fileEntry = formData.find((entry) => entry.name === 'file') 24 | if (fileEntry?.data) { 25 | fileContent = Buffer.from(fileEntry.data) 26 | } 27 | } 28 | 29 | /** 30 | * Try to access the note and if it exists throw a specific error (it exists) 31 | */ 32 | //If folder already exists check 33 | const notebookExists = await checkIfPathExists(mdPath) 34 | if (notebookExists) 35 | throw createError({ 36 | statusCode: 409, 37 | statusMessage: 'Conflict', 38 | message: 'Notebook already exists' 39 | }) 40 | 41 | console.log(mdPath) 42 | await writeFile(mdPath, fileContent) 43 | const stats = await stat(mdPath) 44 | const createdAtTime = stats.birthtime.getTime() !== 0 ? stats.birthtime : stats.ctime 45 | 46 | /** 47 | * ! By default new creations are Markdown 48 | */ 49 | return { 50 | notebook: notebook, 51 | name: isManualFile ? note : `${note}.md`, 52 | createdAt: createdAtTime.toISOString(), 53 | updatedAt: stats.mtime.toISOString(), 54 | size: stats.size, 55 | 56 | isMarkdown: true 57 | } satisfies Note 58 | }, 59 | { 60 | noteCheck: false 61 | } 62 | ) 63 | -------------------------------------------------------------------------------- /server/api/note/[...path].put.ts: -------------------------------------------------------------------------------- 1 | import { rename, access, constants, stat } from 'node:fs/promises' 2 | import { join } from 'node:path' 3 | import { defineEventHandlerWithNotebookAndNote } from '~/server/wrappers/note' 4 | import type { RenameNote } from '~/types/notebook' 5 | // import { waitforme } from '~/server/utils' 6 | 7 | /** 8 | * Renaming note 9 | */ 10 | export default defineEventHandlerWithNotebookAndNote( 11 | async (event, notebook, note, fullPath, notebookPath): Promise => { 12 | const body = await readBody(event) 13 | 14 | // Validate and decode parameters 15 | if (!body?.newName) { 16 | throw createError({ 17 | statusCode: 400, 18 | statusMessage: 'Bad Request', 19 | message: 'Missing new name.' 20 | }) 21 | } 22 | 23 | const cleanNewNote = body.newName.replace(/[\\/:*?"<>|]/g, '') 24 | 25 | const newPath = join(notebookPath, cleanNewNote) 26 | 27 | try { 28 | await access(newPath, constants.F_OK) 29 | throw createError({ 30 | statusCode: 409, 31 | statusMessage: 'Conflict', 32 | message: 'New note name already exists' 33 | }) 34 | } catch (error) { 35 | if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error 36 | } 37 | 38 | // Perform rename 39 | await rename(fullPath, newPath) 40 | const stats = await stat(newPath) 41 | 42 | return { 43 | oldName: note, 44 | newName: cleanNewNote, 45 | notebook: notebook, 46 | createdAt: stats.birthtime.toISOString(), 47 | updatedAt: stats.mtime.toISOString() 48 | } satisfies RenameNote 49 | } 50 | ) 51 | -------------------------------------------------------------------------------- /server/api/note/download/[...path].get.ts: -------------------------------------------------------------------------------- 1 | import { sendStream, setHeaders } from 'h3' 2 | import { stat } from 'node:fs/promises' 3 | import { extname } from 'node:path' 4 | import { createReadStream } from 'node:fs' 5 | import contentDisposition from 'content-disposition' 6 | 7 | import { defineEventHandlerWithNotebookAndNote } from '~/server/wrappers/note' 8 | 9 | export default defineEventHandlerWithNotebookAndNote( 10 | async (event, _cleanNotebook, cleanNote, fullPath): Promise => { 11 | //Get info 12 | const stats = await stat(fullPath) 13 | 14 | const createdAtTime = stats.birthtime.getTime() !== 0 ? stats.birthtime : stats.ctime 15 | const createdAt = createdAtTime.toISOString() 16 | const updatedAt = stats.mtime.toISOString() 17 | 18 | // Determine content type based on file extension 19 | const fileExtension = extname(fullPath).toLowerCase() 20 | 21 | const contentType = fileExtension === '.md' ? 'text/markdown' : 'text/plain' 22 | // Set appropriate headers 23 | setHeaders(event, { 24 | 'Content-Type': contentType, 25 | 'Content-Disposition': contentDisposition(`${cleanNote}`, { type: 'attachment' }), 26 | 'Cache-Control': 'no-cache', 27 | 'Content-Created': createdAt, 28 | 'Content-Updated': updatedAt 29 | }) 30 | 31 | // Return file stream 32 | return sendStream(event, createReadStream(fullPath)) 33 | } 34 | ) 35 | -------------------------------------------------------------------------------- /server/api/note/download/pdf/[...path].get.ts: -------------------------------------------------------------------------------- 1 | // import { sendStream, setHeaders } from 'h3' 2 | import { readFileSync } from 'node:fs' 3 | import contentDisposition from 'content-disposition' 4 | import { defineEventHandlerWithNotebookAndNote } from '~/server/wrappers/note' 5 | import { convertMarkdownToHtml, imageRegex, printPDF, replaceFileContent } from '~/server/utils/html-gen' 6 | import { blockRegex, regex as inlineRegex } from 'milkdown-plugin-file/regex' 7 | 8 | export default defineEventHandlerWithNotebookAndNote(async (event, _cleanNotebook, cleanNote, fullPath) => { 9 | const { origin, host } = getRequestURL(event) 10 | // const nanoid = customAlphabet('abcdefghijklmnop') 11 | 12 | const content = readFileSync(fullPath, 'utf8') 13 | const newContent = content.replace(imageRegex, (matchedUrl) => `${origin}${matchedUrl}`) 14 | 15 | let htmlContent: string = await convertMarkdownToHtml(newContent) 16 | htmlContent = replaceFileContent(htmlContent, true, blockRegex) 17 | htmlContent = replaceFileContent(htmlContent, false, inlineRegex) 18 | 19 | const pdf = await printPDF(htmlContent, origin, host) 20 | 21 | // Set appropriate headers 22 | setHeaders(event, { 23 | 'Content-Type': 'application/pdf', 24 | 'Content-Disposition': contentDisposition(`${cleanNote}.pdf`, { type: 'attachment' }), 25 | 'Cache-Control': 'no-cache' 26 | }) 27 | 28 | return send(event, pdf) 29 | }) 30 | -------------------------------------------------------------------------------- /server/api/notebook/[...path].delete.ts: -------------------------------------------------------------------------------- 1 | import { rm } from 'node:fs/promises' 2 | 3 | import { defineEventHandlerWithNotebook } from '~/server/wrappers/notebook' 4 | import type { DeleteNotebook } from '~/types/notebook' 5 | 6 | /** 7 | * Dekete notebook 8 | */ 9 | export default defineEventHandlerWithNotebook( 10 | async (_event, cleanNotebook, fullPath): Promise => { 11 | // Read file contents and stats 12 | await rm(fullPath, { recursive: true, force: true }) 13 | 14 | return { 15 | notebook: cleanNotebook, 16 | deleted: true, 17 | timestamp: new Date().toISOString() 18 | } satisfies DeleteNotebook 19 | }, 20 | { notebookCheck: true } 21 | ) 22 | -------------------------------------------------------------------------------- /server/api/notebook/[...path].get.ts: -------------------------------------------------------------------------------- 1 | import { readdir, stat } from 'node:fs/promises' 2 | import { join, extname } from 'node:path' 3 | import { defineEventHandlerWithNotebook } from '~/server/wrappers/notebook' 4 | import type { Note, Notebook, NotebookContents } from '~/types/notebook' 5 | /** 6 | * Returns contents for a specific notebook 7 | */ 8 | export default defineEventHandlerWithNotebook(async (_event, notebook, fullPath): Promise => { 9 | // Read directory contents 10 | const files = await readdir(fullPath, { withFileTypes: true }) 11 | const notebookContents: NotebookContents = { 12 | notes: [] as Note[], 13 | path: fullPath, 14 | pathArray: notebook 15 | } 16 | // Process files concurrently 17 | await Promise.all( 18 | files.map(async (dirent) => { 19 | const filePath = join(fullPath, dirent.name) 20 | const stats = await stat(filePath) 21 | const createdAtTime = stats.birthtime.getTime() !== 0 ? stats.birthtime : stats.ctime 22 | if (dirent.isFile()) { 23 | const note = { 24 | name: dirent.name, 25 | notebook: notebook, 26 | createdAt: createdAtTime.toISOString(), 27 | updatedAt: stats.mtime.toISOString(), 28 | size: stats.size / 1024, 29 | isMarkdown: extname(filePath).toLowerCase() === '.md' 30 | } satisfies Note 31 | notebookContents.notes.push(note) 32 | } else if (dirent.isDirectory()) { 33 | const notebookPath = join(fullPath, dirent.name) 34 | const notebookFiles = await readdir(notebookPath, { withFileTypes: true }) 35 | 36 | const { fileCount, folderCount, updatedAt } = await notebookFiles.reduce( 37 | async (accPromise, curr) => { 38 | const acc = await accPromise 39 | 40 | if (curr.isFile()) { 41 | acc.fileCount++ 42 | const fileStats = await stat(curr.parentPath) 43 | const fileUpdatedAt = new Date(Math.max(fileStats.birthtime.getTime(), fileStats.mtime.getTime())) 44 | // Set updatedAt if it's not set or if fileUpdatedAt is more recent 45 | if (!acc.updatedAt || acc.updatedAt < fileUpdatedAt) { 46 | acc.updatedAt = fileUpdatedAt 47 | } 48 | } else if (curr.isDirectory()) { 49 | acc.folderCount++ 50 | } 51 | 52 | return acc 53 | }, 54 | Promise.resolve({ fileCount: 0, folderCount: 0, updatedAt: undefined } as { 55 | fileCount: number 56 | folderCount: number 57 | updatedAt: Date | undefined 58 | }) 59 | ) 60 | 61 | const nestedNotebook = { 62 | name: dirent.name, 63 | createdAt: createdAtTime.toISOString(), 64 | noteCount: fileCount, 65 | notebookCount: folderCount, 66 | notebooks: notebook ?? [], 67 | updatedAt: 68 | updatedAt?.toISOString() ?? 69 | new Date(Math.max(stats.birthtime.getTime(), stats.mtime.getTime())).toISOString(), 70 | path: notebookPath 71 | } satisfies Notebook 72 | if (!notebookContents.notebooks) { 73 | notebookContents.notebooks = {} 74 | } 75 | notebookContents.notebooks[nestedNotebook.name] = nestedNotebook 76 | } 77 | }) 78 | ) 79 | return notebookContents 80 | }) 81 | -------------------------------------------------------------------------------- /server/api/notebook/[...path].post.ts: -------------------------------------------------------------------------------- 1 | import { mkdir } from 'node:fs/promises' 2 | import type { Notebook } from '~/types/notebook' 3 | import { defineEventHandlerWithNotebook } from '~/server/wrappers/notebook' 4 | import { checkIfPathExists } from '~/server/utils' 5 | 6 | /** 7 | * Create notebook 8 | */ 9 | export default defineEventHandlerWithNotebook( 10 | async (_event, notebook, fullPath, _parentFolder, name): Promise => { 11 | //If folder already exists check 12 | const notebookExists = await checkIfPathExists(fullPath) 13 | if (notebookExists) 14 | throw createError({ 15 | statusCode: 409, 16 | statusMessage: 'Conflict', 17 | message: 'Notebook already exists' 18 | }) 19 | 20 | try { 21 | // Create the directory 22 | await mkdir(fullPath) 23 | 24 | // Return the new notebook structure matching your type 25 | return { 26 | notebooks: notebook.slice(0, -1) ?? [], 27 | name: name ?? '', 28 | createdAt: new Date().toISOString(), 29 | updatedAt: null, 30 | notebookCount: 0, 31 | noteCount: 0, 32 | path: fullPath 33 | } satisfies Notebook 34 | } catch (error) { 35 | console.error('Error creating notebook:', error) 36 | throw createError({ 37 | statusCode: 500, 38 | statusMessage: 'Internal Server Error', 39 | message: 'Failed to create notebook' 40 | }) 41 | } 42 | }, 43 | { notebookCheck: false } 44 | ) 45 | -------------------------------------------------------------------------------- /server/api/notebook/[...path].put.ts: -------------------------------------------------------------------------------- 1 | import { rename, access, constants, stat } from 'node:fs/promises' 2 | import { join, resolve } from 'node:path' 3 | import { defineEventHandlerWithNotebook } from '~/server/wrappers/notebook' 4 | import type { RenameNotebook } from '~/types/notebook' 5 | 6 | /** 7 | * Rename notebook 8 | */ 9 | export default defineEventHandlerWithNotebook( 10 | async (event, notebook, fullPath, parentFolder): Promise => { 11 | const body = await readBody(event) 12 | // Validate input 13 | if (!body?.newName) { 14 | throw createError({ 15 | statusCode: 400, 16 | statusMessage: 'Bad Request', 17 | message: 'Missing new name for notebook.' 18 | }) 19 | } 20 | 21 | const newName = decodeURIComponent(body.newName) 22 | const cleanNewName = newName.replace(/[\\/:*?"<>|.]/g, '') 23 | 24 | // Construct paths 25 | const newPath = resolve(join(parentFolder, cleanNewName)) 26 | 27 | // Check if new name exists 28 | try { 29 | await access(newPath, constants.F_OK) 30 | throw createError({ 31 | statusCode: 409, 32 | statusMessage: 'Conflict', 33 | message: 'New notebook name already exists' 34 | }) 35 | } catch (error) { 36 | if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error 37 | } 38 | 39 | // Perform rename 40 | await rename(fullPath, newPath) 41 | 42 | // Get updated stats 43 | const stats = await stat(newPath) 44 | const notebookName = notebook.at(-1) ?? '' 45 | 46 | return { 47 | oldName: notebookName, 48 | newName: cleanNewName, 49 | createdAt: stats.birthtime.toISOString(), 50 | updatedAt: stats.mtime.toISOString(), 51 | notebooks: notebook.slice(0, -1), // Have to slice off itself since the notebooks is built off the fetch url which in this case includes this book 52 | path: newPath 53 | } satisfies RenameNotebook 54 | }, 55 | { notebookCheck: false } 56 | ) 57 | -------------------------------------------------------------------------------- /server/api/notes.get.ts: -------------------------------------------------------------------------------- 1 | // server/api/notes.ts 2 | import path from 'node:path' 3 | import type { Note } from '~/types/notebook' 4 | import { notesPath } from '~/server/folder' 5 | import fg from 'fast-glob' 6 | import { defineEventHandlerWithError } from '../wrappers/error' 7 | 8 | export default defineEventHandlerWithError(async (event): Promise => { 9 | const query = getQuery<{ display: number }>(event) 10 | const displayCount = Math.min(Math.max(Number(query.display) || 5, 1), 100) // Clamp between 1-100 11 | 12 | const files = await fg('**/*', { 13 | cwd: notesPath, 14 | absolute: true, 15 | stats: true, 16 | onlyFiles: true, 17 | suppressErrors: true // Handle permission issues gracefully 18 | }) 19 | 20 | // Sort by most recently modified first and take top N 21 | const recentFiles = files.sort((a, b) => b.stats!.mtimeMs - a.stats!.mtimeMs).slice(0, displayCount) 22 | 23 | const notes: Note[] = recentFiles.map((file) => { 24 | const relativePath = path.relative(notesPath, file.path) 25 | const notebook = 26 | path.dirname(relativePath) !== '.' ? path.dirname(relativePath).split(path.sep).filter(Boolean) : [] 27 | 28 | return { 29 | name: file.name, 30 | createdAt: file.stats!.birthtime.toISOString(), 31 | updatedAt: file.stats!.mtime.toISOString(), 32 | notebook, 33 | size: Math.round(file.stats!.size / 1024), 34 | isMarkdown: path.extname(file.path).toLowerCase() === '.md' 35 | } 36 | }) 37 | 38 | return notes 39 | }) 40 | -------------------------------------------------------------------------------- /server/api/search.get.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import { platform } from 'node:os' 3 | import type { ExecSyncOptionsWithStringEncoding } from 'node:child_process' 4 | import { execSync } from 'node:child_process' 5 | import escape from 'shell-escape' 6 | import type { SearchResult } from '~/types/notebook' 7 | import { notesPath } from '~/server/folder' 8 | import { defineEventHandlerWithSearch } from '../wrappers/search' 9 | import { defineEventHandlerWithError } from '../wrappers/error' 10 | 11 | const CONTEXT_CHARS = 50 12 | const MAX_RESULTS = 5 13 | 14 | export default defineEventHandlerWithError(async (event): Promise => { 15 | return defineEventHandlerWithSearch(async (_event, searchResults): Promise => { 16 | const fullPath = resolve(notesPath) 17 | const { q: rawQuery } = getQuery(event) 18 | 19 | if (!rawQuery || typeof rawQuery !== 'string') { 20 | throw createError({ statusCode: 400, message: 'Missing query.' }) 21 | } 22 | 23 | const results: SearchResult[] = [] 24 | const query = rawQuery.replace(/[^\w\- ]/g, '').replace(/([.*+?^${}()|[\]\\])/g, '\\$1') // escape regex special chars 25 | const osPlatform = platform() 26 | 27 | // 3. Optimized content search 28 | try { 29 | let command: string 30 | const searchPath = escape([fullPath]) 31 | 32 | if (osPlatform === 'linux') { 33 | command = `grep -r -i -m1 -P -oH ".{0,${CONTEXT_CHARS}}${query}.{0,${CONTEXT_CHARS}}" --binary-files=without-match ${searchPath} || true` 34 | } else if (osPlatform === 'darwin') { 35 | command = `grep -r -i -m1 -E -oH ".{0,${CONTEXT_CHARS}}${query}.{0,${CONTEXT_CHARS}}" --binary-files=without-match ${searchPath} || true` 36 | } else { 37 | // Windows fallback using PowerShell (slower but works) 38 | const escapedQuery = rawQuery.replace(/"/g, '""') 39 | command = 40 | `Get-ChildItem -Path ${searchPath} -Recurse -File | ` + 41 | `Select-String -Pattern "${escapedQuery}" -CaseSensitive:$false | ` + 42 | `Select-Object -First ${MAX_RESULTS} | ` + 43 | `ForEach-Object { "$($_.Path)|~|$($_.Line)" }` 44 | } 45 | 46 | const execOptions: ExecSyncOptionsWithStringEncoding = { 47 | encoding: 'utf-8', 48 | maxBuffer: 1024 * 1024 * 10 // 10MB buffer 49 | } 50 | 51 | // When on Windows, use PowerShell as the shell 52 | if (osPlatform === 'win32') { 53 | execOptions.shell = 'powershell.exe' 54 | } 55 | 56 | const output = execSync(command, execOptions) 57 | 58 | // Parse results 59 | const contentResults = output 60 | .split('\n') 61 | .filter((line) => line.trim()) 62 | .map((line) => { 63 | let filePath, snippet 64 | if (osPlatform === 'win32' && line.includes('|~|')) { 65 | // Windows output (custom delimiter) 66 | ;[filePath, snippet] = line.split('|~|') 67 | } else { 68 | // Linux/macOS output (colon-delimited) 69 | const [p, ...rest] = line.split(':') 70 | filePath = p 71 | snippet = rest.join(':') 72 | } 73 | 74 | // Split paths on both forward and backslashes 75 | const relativePath = filePath.replace(fullPath, '').split(/[/\\]/).filter(Boolean) 76 | 77 | return { 78 | notebook: relativePath.slice(0, relativePath.length - 1), 79 | name: relativePath.at(-1) as string, 80 | snippet: snippet.trim().slice(0, CONTEXT_CHARS * 2), 81 | score: 3, 82 | matchType: 'content' 83 | } satisfies SearchResult 84 | }) 85 | 86 | results.push(...contentResults.filter((r) => r.notebook)) 87 | } catch (error) { 88 | console.log(error) 89 | throw createError({ 90 | statusCode: 500, 91 | statusMessage: 'Internal Server Error', 92 | data: error, 93 | message: 'Unable to search. Check console for details.' 94 | }) 95 | } 96 | 97 | // Deduplicate and sort 98 | const contentResults = Array.from(new Set(results.map((r) => JSON.stringify(r)))) 99 | .map((r) => JSON.parse(r) as SearchResult) 100 | .sort((a, b) => b.score - a.score) 101 | .slice(0, MAX_RESULTS) 102 | 103 | contentResults.push(...searchResults) 104 | return contentResults 105 | })(event) 106 | }) 107 | -------------------------------------------------------------------------------- /server/api/settings/all.get.ts: -------------------------------------------------------------------------------- 1 | import type { SelectSetting } from '~/server/db/schema' 2 | import { settings } from '~/server/db/schema' 3 | import { db } from '~/server/utils/drizzle' 4 | import { defineEventHandlerWithError } from '~/server/wrappers/error' 5 | import type { Result } from '~/types/result' 6 | 7 | export default defineEventHandlerWithError(async (): Promise> => { 8 | const resp = await db.select().from(settings).all() 9 | return { 10 | success: true, 11 | data: resp 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /server/api/settings/index.post.ts: -------------------------------------------------------------------------------- 1 | import type { InsertSetting } from '~/server/db/schema' 2 | import { settings } from '~/server/db/schema' 3 | import { db } from '~/server/utils/drizzle' 4 | import { defineEventHandlerWithError } from '~/server/wrappers/error' 5 | import type { Result } from '~/types/result' 6 | 7 | export default defineEventHandlerWithError(async (event): Promise> => { 8 | const setting = await readBody(event) 9 | 10 | await db 11 | .insert(settings) 12 | .values(setting) 13 | .onConflictDoUpdate({ 14 | target: [settings.setting], // Replace 'key' with your unique column name 15 | set: { value: setting.value } 16 | }) 17 | 18 | return { 19 | success: true, 20 | data: null 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /server/api/settings/shared/[key].delete.ts: -------------------------------------------------------------------------------- 1 | import type { Result } from '~/types/result' 2 | import { defineEventHandlerWithError } from '../../../wrappers/error' 3 | import { shared } from '~/server/db/schema' 4 | // import type { Note } from '~/types/notebook' 5 | 6 | export default defineEventHandlerWithError(async (event): Promise> => { 7 | const key = getRouterParam(event, 'key') 8 | 9 | if (!key) { 10 | throw createError({ 11 | statusCode: 400, 12 | statusMessage: 'Bad Request', 13 | message: 'Share note to be deleted is not specified.' 14 | }) 15 | } 16 | 17 | const deleteNote = await db.delete(shared).where(eq(shared.key, key)) 18 | 19 | if (deleteNote.rowsAffected === 1) { 20 | return { 21 | success: true, 22 | data: null 23 | } 24 | } else { 25 | throw createError({ 26 | statusCode: 500, 27 | statusMessage: 'Internal Server Error', 28 | message: `Expected to delete one share but deleted ${deleteNote.rowsAffected}` 29 | }) 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /server/api/settings/shared/index.get.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandlerWithError } from '../../../wrappers/error' 2 | import { type SelectShared, shared } from '~/server/db/schema' 3 | 4 | // import type { Note } from '~/types/notebook' 5 | 6 | export default defineEventHandlerWithError(async (): Promise => { 7 | const sharedNotes = await db.select().from(shared).all() 8 | 9 | return sharedNotes 10 | }) 11 | -------------------------------------------------------------------------------- /server/api/share/[...path].post.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandlerWithNotebookAndNote } from '../../wrappers/note' 2 | import { shared } from '~/server/db/schema' 3 | import { nanoid } from 'nanoid' 4 | import type { Result } from '~/types/result' 5 | import { notePathArrayJoiner } from '~/utils/path-joiner' 6 | 7 | export default defineEventHandlerWithNotebookAndNote( 8 | async (event, notebooks, note, fullPath): Promise> => { 9 | const { name } = await readBody(event) 10 | 11 | console.log(`Attempting to share note at: ${fullPath}`) 12 | 13 | const sharingKey = nanoid(40) 14 | 15 | try { 16 | await db.insert(shared).values({ 17 | key: sharingKey, 18 | name, 19 | path: '/'.concat(notePathArrayJoiner([...notebooks, note])) 20 | }) 21 | 22 | console.log(`Successfully created share link for "${fullPath}" with key "${sharingKey}"`) 23 | 24 | // Return success response with the generated key 25 | return { 26 | success: true, 27 | data: sharingKey 28 | } 29 | } catch (dbError) { 30 | console.error('Database error while creating share link:', dbError) 31 | // Handle potential database errors (e.g., connection issues, constraint violations) 32 | throw createError({ 33 | statusCode: 500, 34 | statusMessage: 'Internal Server Error', 35 | message: 'Could not create share link for the note due to a database error.' 36 | }) 37 | } 38 | } 39 | ) 40 | -------------------------------------------------------------------------------- /server/api/share/[key].get.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandlerWithError } from '../../wrappers/error' 2 | import { shared } from '~/server/db/schema' 3 | import { join, resolve, extname } from 'node:path' 4 | import { notesPath } from '~/server/folder' 5 | import { access, constants, stat } from 'node:fs/promises' 6 | import { readFileSync } from 'node:fs' 7 | import { fullRegex } from '~/server/utils/html-gen' 8 | import jwt from 'jsonwebtoken' 9 | import SECRET_KEY from '~/server/key' 10 | 11 | // import type { Note } from '~/types/notebook' 12 | 13 | export default defineEventHandlerWithError(async (event) => { 14 | const key = decodeURIComponent(event.context.params?.key ?? '') 15 | 16 | if (!key) 17 | return { 18 | success: false, 19 | message: 'Sharing key is required' 20 | } 21 | 22 | const note = await db.query.shared.findFirst({ 23 | where: eq(shared.key, key) 24 | }) 25 | 26 | if (!note) { 27 | return { 28 | success: false, 29 | message: 'No shared note found' 30 | } 31 | } 32 | 33 | const fullPath = resolve(join(notesPath, ...note.path.split('/'))) 34 | 35 | try { 36 | // Verify notebook and note exist and is read/write allowed 37 | await access(fullPath, constants.R_OK | constants.W_OK) 38 | } catch (error) { 39 | console.error('Note error:', error) 40 | const message = 'Access error: Applicaiton does not have access to the note or is no longer on filesystem.' 41 | throw createError({ 42 | statusCode: 404, 43 | statusMessage: 'Not Found', 44 | message 45 | }) 46 | } 47 | 48 | const stats = await stat(fullPath) 49 | 50 | const createdAtTime = stats.birthtime.getTime() !== 0 ? stats.birthtime : stats.ctime 51 | const createdAt = createdAtTime.toISOString() 52 | const updatedAt = stats.mtime.toISOString() 53 | 54 | // Determine content type based on file extension 55 | const fileExtension = extname(fullPath).toLowerCase() 56 | 57 | const contentType = fileExtension === '.md' ? 'text/markdown' : 'text/plain' 58 | 59 | const content = readFileSync(fullPath, 'utf8') 60 | 61 | const result = content.matchAll(fullRegex) 62 | const attachments = Array.from(result, (match) => match[0].split('/').at(-1)) 63 | 64 | const token = jwt.sign({ app: 'nanote', attachments }, SECRET_KEY, { expiresIn: '1d', audience: 'shared' }) 65 | 66 | setCookie(event, 'token', token, { 67 | httpOnly: true, 68 | sameSite: 'strict', 69 | maxAge: 3600 * 24 * 1, // 1 day 70 | path: '/' 71 | }) 72 | 73 | // Set appropriate headers 74 | setHeaders(event, { 75 | 'Content-Type': contentType, 76 | 'Content-Disposition': `attachment; filename="${fullPath.split('/').at(-1)}"`, 77 | 'Cache-Control': 'no-cache', 78 | 'Content-Created': createdAt, 79 | 'Content-Updated': updatedAt 80 | }) 81 | 82 | // Return file stream 83 | return send(event, content) 84 | }) 85 | -------------------------------------------------------------------------------- /server/db/migrations/0000_ambiguous_taskmaster.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `settings` ( 2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `setting` text NOT NULL, 4 | `value` text NOT NULL 5 | ); 6 | --> statement-breakpoint 7 | CREATE UNIQUE INDEX `settings_setting_unique` ON `settings` (`setting`); -------------------------------------------------------------------------------- /server/db/migrations/0001_confused_blade.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `shared` ( 2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `path` text NOT NULL, 4 | `key` text NOT NULL, 5 | `writable` integer DEFAULT false NOT NULL, 6 | `expiry` integer 7 | ); 8 | -------------------------------------------------------------------------------- /server/db/migrations/0002_silent_deadpool.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `shared` ADD `name` text; -------------------------------------------------------------------------------- /server/db/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "d8c9bbd0-7fbe-446e-8862-951c0151a9d8", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "settings": { 8 | "name": "settings", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "setting": { 18 | "name": "setting", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "value": { 25 | "name": "value", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | } 31 | }, 32 | "indexes": { 33 | "settings_setting_unique": { 34 | "name": "settings_setting_unique", 35 | "columns": [ 36 | "setting" 37 | ], 38 | "isUnique": true 39 | } 40 | }, 41 | "foreignKeys": {}, 42 | "compositePrimaryKeys": {}, 43 | "uniqueConstraints": {}, 44 | "checkConstraints": {} 45 | } 46 | }, 47 | "views": {}, 48 | "enums": {}, 49 | "_meta": { 50 | "schemas": {}, 51 | "tables": {}, 52 | "columns": {} 53 | }, 54 | "internal": { 55 | "indexes": {} 56 | } 57 | } -------------------------------------------------------------------------------- /server/db/migrations/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "b1b1d7da-058f-4d0f-ab39-2aae5d888085", 5 | "prevId": "d8c9bbd0-7fbe-446e-8862-951c0151a9d8", 6 | "tables": { 7 | "settings": { 8 | "name": "settings", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "setting": { 18 | "name": "setting", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "value": { 25 | "name": "value", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | } 31 | }, 32 | "indexes": { 33 | "settings_setting_unique": { 34 | "name": "settings_setting_unique", 35 | "columns": [ 36 | "setting" 37 | ], 38 | "isUnique": true 39 | } 40 | }, 41 | "foreignKeys": {}, 42 | "compositePrimaryKeys": {}, 43 | "uniqueConstraints": {}, 44 | "checkConstraints": {} 45 | }, 46 | "shared": { 47 | "name": "shared", 48 | "columns": { 49 | "id": { 50 | "name": "id", 51 | "type": "integer", 52 | "primaryKey": true, 53 | "notNull": true, 54 | "autoincrement": true 55 | }, 56 | "path": { 57 | "name": "path", 58 | "type": "text", 59 | "primaryKey": false, 60 | "notNull": true, 61 | "autoincrement": false 62 | }, 63 | "key": { 64 | "name": "key", 65 | "type": "text", 66 | "primaryKey": false, 67 | "notNull": true, 68 | "autoincrement": false 69 | }, 70 | "writable": { 71 | "name": "writable", 72 | "type": "integer", 73 | "primaryKey": false, 74 | "notNull": true, 75 | "autoincrement": false, 76 | "default": false 77 | }, 78 | "expiry": { 79 | "name": "expiry", 80 | "type": "integer", 81 | "primaryKey": false, 82 | "notNull": false, 83 | "autoincrement": false 84 | } 85 | }, 86 | "indexes": {}, 87 | "foreignKeys": {}, 88 | "compositePrimaryKeys": {}, 89 | "uniqueConstraints": {}, 90 | "checkConstraints": {} 91 | } 92 | }, 93 | "views": {}, 94 | "enums": {}, 95 | "_meta": { 96 | "schemas": {}, 97 | "tables": {}, 98 | "columns": {} 99 | }, 100 | "internal": { 101 | "indexes": {} 102 | } 103 | } -------------------------------------------------------------------------------- /server/db/migrations/meta/0002_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "f6e610ce-e772-45c7-82bb-3c6b97048456", 5 | "prevId": "b1b1d7da-058f-4d0f-ab39-2aae5d888085", 6 | "tables": { 7 | "settings": { 8 | "name": "settings", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "setting": { 18 | "name": "setting", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "value": { 25 | "name": "value", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | } 31 | }, 32 | "indexes": { 33 | "settings_setting_unique": { 34 | "name": "settings_setting_unique", 35 | "columns": [ 36 | "setting" 37 | ], 38 | "isUnique": true 39 | } 40 | }, 41 | "foreignKeys": {}, 42 | "compositePrimaryKeys": {}, 43 | "uniqueConstraints": {}, 44 | "checkConstraints": {} 45 | }, 46 | "shared": { 47 | "name": "shared", 48 | "columns": { 49 | "id": { 50 | "name": "id", 51 | "type": "integer", 52 | "primaryKey": true, 53 | "notNull": true, 54 | "autoincrement": true 55 | }, 56 | "path": { 57 | "name": "path", 58 | "type": "text", 59 | "primaryKey": false, 60 | "notNull": true, 61 | "autoincrement": false 62 | }, 63 | "key": { 64 | "name": "key", 65 | "type": "text", 66 | "primaryKey": false, 67 | "notNull": true, 68 | "autoincrement": false 69 | }, 70 | "name": { 71 | "name": "name", 72 | "type": "text", 73 | "primaryKey": false, 74 | "notNull": false, 75 | "autoincrement": false 76 | }, 77 | "writable": { 78 | "name": "writable", 79 | "type": "integer", 80 | "primaryKey": false, 81 | "notNull": true, 82 | "autoincrement": false, 83 | "default": false 84 | }, 85 | "expiry": { 86 | "name": "expiry", 87 | "type": "integer", 88 | "primaryKey": false, 89 | "notNull": false, 90 | "autoincrement": false 91 | } 92 | }, 93 | "indexes": {}, 94 | "foreignKeys": {}, 95 | "compositePrimaryKeys": {}, 96 | "uniqueConstraints": {}, 97 | "checkConstraints": {} 98 | } 99 | }, 100 | "views": {}, 101 | "enums": {}, 102 | "_meta": { 103 | "schemas": {}, 104 | "tables": {}, 105 | "columns": {} 106 | }, 107 | "internal": { 108 | "indexes": {} 109 | } 110 | } -------------------------------------------------------------------------------- /server/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1747615199469, 9 | "tag": "0000_ambiguous_taskmaster", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "6", 15 | "when": 1747615677779, 16 | "tag": "0001_confused_blade", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "6", 22 | "when": 1747876545801, 23 | "tag": "0002_silent_deadpool", 24 | "breakpoints": true 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /server/db/schema.ts: -------------------------------------------------------------------------------- 1 | import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' 2 | import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' 3 | 4 | export const settings = sqliteTable('settings', { 5 | id: integer('id').primaryKey({ autoIncrement: true }), 6 | setting: text('setting').notNull().unique(), 7 | value: text('value').notNull() 8 | }) 9 | 10 | export const shared = sqliteTable('shared', { 11 | id: integer('id').primaryKey({ autoIncrement: true }), 12 | path: text('path').notNull(), 13 | key: text('key').notNull(), 14 | name: text('name'), 15 | isWriteable: integer('writable', { mode: 'boolean' }).notNull().default(false), 16 | expiry: integer({ mode: 'timestamp' }) 17 | }) 18 | 19 | export type SelectSetting = InferSelectModel 20 | export type InsertSetting = InferInsertModel 21 | export type SelectShared = InferSelectModel 22 | -------------------------------------------------------------------------------- /server/folder.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path' 2 | import { existsSync, mkdirSync } from 'node:fs' 3 | 4 | // Define the default path (e.g., a "notes" folder in your project directory) 5 | const defaultPath = process.cwd() 6 | const defaultNotesPath = join(defaultPath, 'notes') 7 | const defaultUploadsPath = join(defaultPath, 'uploads') 8 | const defaultConfigPath = join(defaultPath, 'config') 9 | // Get the environment variable value (if any) 10 | const envNotesPath = process.env.NOTES_PATH 11 | const envUploadsPath = process.env.UPLOAD_PATH 12 | const envConfigPath = process.env.CONFIG_PATH 13 | 14 | // Use the env variable if it's provided and the directory exists, 15 | // otherwise fall back to the default path. 16 | const notesPath = envNotesPath && existsSync(envNotesPath) ? envNotesPath : defaultNotesPath 17 | const uploadPath = envUploadsPath && existsSync(envUploadsPath) ? envUploadsPath : defaultUploadsPath 18 | const configPath = envConfigPath && existsSync(envConfigPath) ? envConfigPath : defaultConfigPath 19 | 20 | try { 21 | // Ensure directories exist 22 | if (!existsSync(notesPath)) { 23 | mkdirSync(notesPath, { recursive: true }) 24 | } 25 | 26 | if (!existsSync(uploadPath)) { 27 | mkdirSync(uploadPath, { recursive: true }) 28 | } 29 | 30 | if (!existsSync(configPath)) { 31 | mkdirSync(configPath, { recursive: true }) 32 | } 33 | } catch (error) { 34 | console.log('Unable to create folders') 35 | console.log(error) 36 | } 37 | 38 | const dbSystemPath = join(configPath, 'data.db') 39 | const dbPath = `file:${dbSystemPath}` 40 | 41 | const tempPath = join(configPath, 'temp') 42 | if (!existsSync(tempPath)) mkdirSync(tempPath, { recursive: true }) 43 | 44 | export { 45 | notesPath, 46 | uploadPath, 47 | envNotesPath, 48 | envUploadsPath, 49 | envConfigPath, 50 | configPath, 51 | dbPath, 52 | dbSystemPath, 53 | tempPath, 54 | defaultPath 55 | } 56 | -------------------------------------------------------------------------------- /server/key.ts: -------------------------------------------------------------------------------- 1 | const envSecretKey = process.env.SECRET_KEY 2 | 3 | const SECRET_KEY = envSecretKey ?? 'nanote' 4 | 5 | export default SECRET_KEY 6 | -------------------------------------------------------------------------------- /server/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import { checkLogin } from '../utils' 2 | 3 | export default defineEventHandler((event) => { 4 | // return // bypass for dev 5 | if ( 6 | !event.path.startsWith('/api/') || 7 | event.path === '/api/auth/login' || 8 | event.path === '/api/health' || 9 | event.path.startsWith('/api/share') || 10 | event.path.startsWith('/api/attachment/') // Attachment has its own auth logic 11 | ) 12 | return 13 | 14 | const cookie = getCookie(event, 'token') 15 | 16 | const verifyResult = checkLogin(cookie) 17 | 18 | if (!verifyResult.success) 19 | throw createError({ 20 | statusCode: 401, 21 | statusMessage: 'Unauthorized', 22 | message: verifyResult.message 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /server/plugins/drizzle.migrate.ts: -------------------------------------------------------------------------------- 1 | // server/plugins/drizzle.migrate.ts 2 | import { migrate } from 'drizzle-orm/libsql/migrator' // Or your SQLite driver's migrator 3 | import { access, constants } from 'node:fs/promises' 4 | import { dbSystemPath } from '~/server/folder' 5 | import { db } from '~/server/utils/drizzle' 6 | import { createError } from 'h3' // ← This import is mandatory 7 | 8 | export default defineNitroPlugin(async (app) => { 9 | if (process.env.NODE_ENV === 'development') { 10 | console.log('Skipping automatic migrations in development mode.') 11 | return 12 | } 13 | 14 | console.log(`Checking database status for SQLite file at: ${dbSystemPath}`) 15 | 16 | try { 17 | await access(dbSystemPath, constants.R_OK | constants.W_OK) 18 | } catch (error) { 19 | console.error('Database error:', error) 20 | 21 | const err = error as NodeJS.ErrnoException 22 | const message = err.code === 'ENOENT' ? 'Database does not exist' : 'Database exists but is not accessible' 23 | 24 | console.error(`Failed to access database:`, message) 25 | 26 | const errorObj = createError({ 27 | statusCode: 500, 28 | statusMessage: `Internal Server Error`, 29 | message: `Failed to acess database: ${message}. Application cannot start.` 30 | }) 31 | 32 | app.hooks.hook('request', (event) => { 33 | return sendError(event, errorObj) 34 | }) 35 | } 36 | 37 | try { 38 | console.log('Applying database migrations...') 39 | // 'db' is your Drizzle instance, already connected to the SQLite file. 40 | // Drizzle's migrate function will create the migrations table if it doesn't exist 41 | // and apply any pending migrations from the specified folder. 42 | await migrate(db, { migrationsFolder: 'server/db/migrations' }) // Adjust path if your migrations are elsewhere 43 | console.log('Database migrations applied successfully.') 44 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 45 | } catch (error: any) { 46 | console.error('CRITICAL: Database migration failed:', error.message) 47 | 48 | const errorObj = createError({ 49 | statusCode: 500, 50 | statusMessage: `Internal Server Error`, 51 | message: `A critical error occurred during database migrations: ${error.message}. Application cannot start.` 52 | }) 53 | 54 | app.hooks.hook('request', (event) => { 55 | return sendError(event, errorObj) 56 | }) 57 | } 58 | }) 59 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /server/utils.ts: -------------------------------------------------------------------------------- 1 | import { access, constants } from 'node:fs/promises' 2 | import jwt from 'jsonwebtoken' 3 | import SECRET_KEY from '~/server/key' 4 | import type { Result } from '~/types/result' 5 | 6 | export function waitforme(millisec: number) { 7 | return new Promise((resolve) => { 8 | setTimeout(() => { 9 | resolve('') 10 | }, millisec) 11 | }) 12 | } 13 | 14 | export const checkIfPathExists = async (fullPath: string): Promise => { 15 | try { 16 | await access(fullPath, constants.F_OK) 17 | return true 18 | } catch (error) { 19 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 20 | return false // Folder does not exist 21 | } 22 | throw error // Some other error occurred 23 | } 24 | } 25 | 26 | export const checkLogin = ( 27 | cookie?: string, 28 | options?: jwt.VerifyOptions & { 29 | complete?: false 30 | } 31 | ): Result => { 32 | if (!cookie) return { success: false, message: 'Please login first' } 33 | 34 | try { 35 | const decoded = jwt.verify(cookie, SECRET_KEY, options ?? undefined) 36 | return { success: true, data: decoded } 37 | } catch { 38 | return { success: false, message: 'Unable to verify authentication.' } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /server/utils/drizzle.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/libsql' 2 | import * as schema from '~/server/db/schema' 3 | import { dbPath } from '~/server/folder' 4 | 5 | export const tables = schema 6 | 7 | export const db = drizzle(dbPath, { schema }) 8 | 9 | export type Settings = typeof schema.settings.$inferSelect 10 | export { sql, eq, and, or } from 'drizzle-orm' 11 | -------------------------------------------------------------------------------- /server/utils/html-gen.ts: -------------------------------------------------------------------------------- 1 | // markdownConverter.js 2 | import { unified } from 'unified' 3 | import remarkParse from 'remark-parse' 4 | import remarkHtml from 'remark-html' 5 | import { getIcon } from 'material-file-icons' 6 | import puppeteer from 'puppeteer' 7 | import { settings } from '~/server/db/schema' 8 | import jwt from 'jsonwebtoken' 9 | import SECRET_KEY from '~/server/key' 10 | 11 | export const fullRegex = /(?<=\(<|\()\/api\/attachment\/.*?(?=[)>])|(?<=href=")\/api\/attachment\/.*?(?=")/g 12 | export const imageRegex = /(?<=\(<|\()\/api\/attachment\/.*?(?=[)>])/g 13 | 14 | export const convertMarkdownToHtml = async (markdownContent: string) => { 15 | try { 16 | const file = await unified() 17 | .use(remarkParse) // Parse the markdown string into an AST 18 | .use(remarkHtml) // Convert the AST into an HTML string 19 | .process(markdownContent) // Process the content 20 | 21 | return String(file) // Convert the VFile (virtual file) to a string 22 | } catch (error) { 23 | console.error('Error converting Markdown to HTML:', error) 24 | throw error 25 | } 26 | } 27 | 28 | export const replaceFileContent = (htmlContent: string, isBlock: boolean, regex: RegExp) => { 29 | htmlContent = htmlContent.replace(regex, (match, _hrefGroup, _titleGroup, _offset, _originalString, groups) => { 30 | // The `groups` object (the last argument) contains the named capture groups. 31 | // groups.title should contain the content of the title attribute. 32 | const title = groups?.title ?? 'N/A' 33 | const icon = getIcon(title) 34 | if (title) { 35 | return ` 36 | 37 | ${isBlock ? 'File: ' : ''}[${icon.svg} ${title.trim()}] 38 | 39 | ` // .trim() to remove any potential leading/trailing spaces within the quotes 40 | } 41 | return match // Fallback: if title is not captured for some reason, return the original match. 42 | }) 43 | return htmlContent 44 | } 45 | 46 | export const printPDF = async (html: string, origin: string, hostname: string) => { 47 | const token = jwt.sign({ app: 'nanote' }, SECRET_KEY, { expiresIn: '7d', audience: 'authorized' }) 48 | 49 | const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }) 50 | // Create a new browser context 51 | const context = await browser.createBrowserContext() 52 | context.setCookie({ 53 | httpOnly: true, 54 | name: 'token', 55 | value: token, 56 | domain: hostname, 57 | sameSite: 'Strict', 58 | expires: Math.floor(Date.now() / 1000) + 60 * 5, // expires in 5 minutes 59 | path: '/' 60 | }) 61 | 62 | try { 63 | const page = await context.newPage() 64 | await page.goto(origin) 65 | 66 | await page.setContent(html, { waitUntil: 'networkidle0' }) 67 | await page.addStyleTag({ 68 | content: `@media print { 69 | @page { 70 | margin: 1cm 1.5cm; /* top-bottom, left-right */ 71 | } 72 | html { 73 | font-family: sans-serif; 74 | } 75 | 76 | img { 77 | max-width: 100%; 78 | } 79 | 80 | body { 81 | /* Optional: avoid extra spacing issues */ 82 | margin: 0; 83 | } 84 | }` 85 | }) 86 | 87 | const paraSpacing = await db.query.settings.findFirst({ 88 | where: eq(settings.setting, 'isParagraphSpaced') 89 | }) 90 | if (paraSpacing?.value === 'true') { 91 | await page.addStyleTag({ content: 'p {padding-bottom: 0.25rem}' }) 92 | } 93 | 94 | const pdf = await page.pdf({ format: 'A4' }) 95 | 96 | await browser.close() 97 | return pdf 98 | } catch (error) { 99 | console.log(error) 100 | await browser.close() 101 | throw new Error(`PDF generation failed: ${error instanceof Error ? error.message : String(error)}`) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /server/wrappers/attachment-auth.ts: -------------------------------------------------------------------------------- 1 | import type { EventHandlerRequest, H3Event } from 'h3' 2 | import { defineEventHandlerWithError } from './error' 3 | import { checkLogin } from '../utils' 4 | 5 | type EventHandlerWithError = (event: H3Event) => Promise 6 | 7 | export function defineEventHandlerWithAttachmentAuthError( 8 | handler: EventHandlerWithError 9 | ) { 10 | return defineEventHandlerWithError(async (event) => { 11 | // Just a regular user session 12 | const cookie = getCookie(event, 'token') 13 | const verifyResult = checkLogin(cookie, { audience: 'authorized' }) 14 | if (verifyResult.success) return await handler(event) 15 | 16 | const verifyShared = checkLogin(cookie, { audience: 'shared' }) 17 | console.log('verifyShared', verifyShared) 18 | if (verifyShared.success) { 19 | const file = decodeURIComponent(getRouterParam(event, 'file') ?? '') 20 | // @ts-expect-error dynamic attachments 21 | const rawAttachments = verifyShared.data.attachments 22 | const attachments = 23 | Array.isArray(rawAttachments) && rawAttachments.every((a) => typeof a === 'string') 24 | ? (rawAttachments as string[]) 25 | : [] 26 | if (attachments.includes(file)) return await handler(event) 27 | } 28 | 29 | throw createError({ 30 | statusCode: 401, 31 | statusMessage: 'Unauthorized', 32 | message: 'Unauthorized access.' 33 | }) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /server/wrappers/attachment.ts: -------------------------------------------------------------------------------- 1 | import type { EventHandlerRequest, H3Event } from 'h3' 2 | import { defineEventHandlerWithNotebookAndNote } from './note' 3 | import { readFile, unlink } from 'node:fs/promises' 4 | import { join } from 'node:path' 5 | import { uploadPath } from '../folder' 6 | 7 | const fileRegex = /::(file|fileBlock)\{href="(?[^"]+)?"? title="(?[^"]+)?"?\}/g 8 | 9 | type EventHandlerWithAttachment<T extends EventHandlerRequest, D> = ( 10 | event: H3Event<T>, 11 | notebooks: string[], 12 | note: string, 13 | fullPath: string, 14 | markAttachmentForDeletionIfNeeded: (newFileData: Buffer<ArrayBufferLike> | null) => Promise<void>, 15 | deleteAllAttachments: () => Promise<void> 16 | ) => Promise<D> 17 | 18 | export function defineEventHandlerWithAttachmentNotebookNote<T extends EventHandlerRequest, D>( 19 | handler: EventHandlerWithAttachment<T, D> 20 | ) { 21 | return defineEventHandlerWithNotebookAndNote( 22 | async (event, notebooks, note, fullPath) => { 23 | const oldNoteContent = await readFile(fullPath, 'utf-8') 24 | 25 | const deleteAllAttachments = async () => { 26 | const oldMatches = [...oldNoteContent.matchAll(fileRegex)] 27 | .map((match) => match.groups?.href.split('/').at(-1)) 28 | .filter((item) => item !== undefined) 29 | 30 | for (const match of oldMatches) { 31 | const filePath = join(uploadPath, 'attachments', match) 32 | try { 33 | await unlink(filePath) 34 | } catch (error) { 35 | if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { 36 | throw error 37 | } 38 | } 39 | } 40 | } 41 | 42 | const markAttachmentForDeletionIfNeeded = async (newFileData: Buffer<ArrayBufferLike> | null) => { 43 | const newNoteContent = (newFileData ?? '').toString() 44 | 45 | const newMatches = [...newNoteContent.matchAll(fileRegex)] 46 | .map((match) => match.groups?.href.split('/').at(-1)) 47 | .filter((item) => item !== undefined) 48 | 49 | const oldMatches = [...oldNoteContent.matchAll(fileRegex)] 50 | .map((match) => match.groups?.href.split('/').at(-1)) 51 | .filter((item) => item !== undefined) 52 | 53 | const uniqueMatches = oldMatches.filter((item) => !newMatches.includes(item)) 54 | 55 | for (const match of uniqueMatches) { 56 | const filePath = join(uploadPath, 'attachments', match) 57 | try { 58 | await unlink(filePath) 59 | } catch (error) { 60 | if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { 61 | throw error 62 | } 63 | } 64 | } 65 | } 66 | 67 | try { 68 | return await handler(event, notebooks, note, fullPath, markAttachmentForDeletionIfNeeded, deleteAllAttachments) 69 | } catch (error) { 70 | if (error instanceof Error && 'statusCode' in error) { 71 | throw error 72 | } 73 | throw createError({ 74 | statusCode: 500, 75 | statusMessage: 'Internal Server Error', 76 | message: 'Failed to process attachment' 77 | }) 78 | } 79 | }, 80 | { noteCheck: true } 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /server/wrappers/error.ts: -------------------------------------------------------------------------------- 1 | import type { EventHandlerRequest, H3Event } from 'h3' 2 | import type { APIError } from '~/types/result' 3 | 4 | type EventHandlerWithError<T extends EventHandlerRequest, D> = (event: H3Event<T>) => Promise<D> 5 | 6 | export function defineEventHandlerWithError<T extends EventHandlerRequest, D>(handler: EventHandlerWithError<T, D>) { 7 | return defineEventHandler(async (event) => { 8 | try { 9 | return await handler(event) 10 | } catch (error) { 11 | console.log(event, error) 12 | if (error instanceof URIError) { 13 | throw createError({ 14 | statusCode: 400, 15 | statusMessage: 'Bad Request', 16 | message: 'Invalid URL encoding.' 17 | }) 18 | } else if (error instanceof Error && 'statusCode' in error) { 19 | const err = error as APIError 20 | throw createError({ 21 | statusCode: err.statusCode ?? 500, 22 | statusMessage: err.statusMessage ?? 'Internal Server Error', 23 | message: err.message ?? 'An unexpected error occurred' 24 | }) 25 | } else if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') { 26 | throw createError({ 27 | statusCode: 404, 28 | statusMessage: 'Not Found', 29 | message: 'The requested file does not exist.' 30 | }) 31 | } else if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'EACCES') { 32 | throw createError({ 33 | statusCode: 403, 34 | statusMessage: 'Forbidden', 35 | message: 'Permission denied: Cannot access the requested file.' 36 | }) 37 | } else { 38 | throw createError({ 39 | statusCode: 500, 40 | statusMessage: 'Internal Server Error', 41 | message: 'An unexpected error occurred' 42 | }) 43 | } 44 | } 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /server/wrappers/note.ts: -------------------------------------------------------------------------------- 1 | import type { EventHandlerRequest, H3Event } from 'h3' 2 | import { access, constants } from 'node:fs/promises' 3 | import { join, resolve, extname } from 'node:path' 4 | import { notesPath } from '~/server/folder' 5 | import type { APIError } from '~/types/result' 6 | 7 | type EventHandlerWithNotebookAndNote<T extends EventHandlerRequest, D> = ( 8 | event: H3Event<T>, 9 | notebooks: string[], 10 | note: string, 11 | fullPath: string, 12 | notebookPath: string, 13 | isMarkdown: boolean 14 | ) => Promise<D> 15 | 16 | export function defineEventHandlerWithNotebookAndNote<T extends EventHandlerRequest, D>( 17 | handler: EventHandlerWithNotebookAndNote<T, D>, 18 | options?: { noteCheck: boolean } 19 | ) { 20 | return defineEventHandler(async (event) => { 21 | // Decode the path and then remove characters we cannot have 22 | const params = decodeURIComponent(event.context.params?.path ?? '') 23 | const path = params.split('/').map((p) => p.replace(/[\\/:*?"<>|]/g, '')) || [] 24 | const notebooks = path.slice(0, -1) 25 | const note = path.at(-1) 26 | 27 | if (notebooks.length === 0 || !note) { 28 | throw createError({ 29 | statusCode: 400, 30 | statusMessage: 'Bad Request', 31 | message: 'Missing notebook or note name' 32 | }) 33 | } 34 | 35 | // Construct paths 36 | const targetFolder = resolve(join(notesPath, ...notebooks)) 37 | const filename = note 38 | const fullPath = join(targetFolder, filename) 39 | 40 | const fileExtension = extname(fullPath).toLowerCase() 41 | const isMarkdown = fileExtension === '.md' 42 | 43 | //Is the name going to exceed limits? 44 | if (note.length > 255) { 45 | throw createError({ 46 | statusCode: 400, 47 | statusMessage: 'Bad Request', 48 | message: `Name exceeds maximum allowed length of 255 characters.` 49 | }) 50 | } 51 | 52 | // Add OS path length validation 53 | const isWindows = process.platform === 'win32' 54 | const maxPathLength = isWindows ? 259 : 4095 // Same limits as folder creation 55 | 56 | if (fullPath.length > maxPathLength) { 57 | throw createError({ 58 | statusCode: 400, 59 | statusMessage: 'Bad Request', 60 | message: `Path exceeds maximum allowed length of ${maxPathLength} characters.` 61 | }) 62 | } 63 | 64 | // Security checks 65 | if (!targetFolder.startsWith(resolve(notesPath))) { 66 | throw createError({ 67 | statusCode: 400, 68 | statusMessage: 'Bad Request', 69 | message: 'Invalid notebook path' 70 | }) 71 | } 72 | 73 | try { 74 | // Verify notebook and note exist and is read/write allowed 75 | await access(targetFolder, constants.R_OK | constants.W_OK) 76 | if (options?.noteCheck) await access(fullPath, constants.R_OK | constants.W_OK) 77 | } catch (error) { 78 | console.error('Note error:', error) 79 | 80 | const err = error as NodeJS.ErrnoException 81 | const message = 82 | err.code === 'ENOENT' 83 | ? err.path === targetFolder 84 | ? `Notebook "${notebooks.join(' > ')}" does not exist` 85 | : `Note "${note}" does not exist` 86 | : 'Access error' 87 | 88 | throw createError({ 89 | statusCode: 404, 90 | statusMessage: 'Not Found', 91 | message 92 | }) 93 | } 94 | try { 95 | return await handler(event, notebooks, note, fullPath, targetFolder, isMarkdown) 96 | } catch (error) { 97 | console.log(event, error) 98 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 99 | throw createError({ 100 | statusCode: 404, 101 | statusMessage: 'Not Found', 102 | message: 'Note or notebook does not exist' 103 | }) 104 | } else if (error instanceof URIError) { 105 | throw createError({ 106 | statusCode: 400, 107 | statusMessage: 'Bad Request', 108 | message: 'Invalid URL encoding.' 109 | }) 110 | } else if (error instanceof Error && 'statusCode' in error) { 111 | const err = error as APIError 112 | throw createError({ 113 | statusCode: err.statusCode ?? 500, 114 | statusMessage: err.statusMessage ?? 'Internal Server Error', 115 | message: err.message ?? 'An unexpected error occurred' 116 | }) 117 | } else { 118 | throw createError({ 119 | statusCode: 500, 120 | statusMessage: 'Internal Server Error', 121 | message: 'An unexpected error occurred' 122 | }) 123 | } 124 | } 125 | }) 126 | } 127 | -------------------------------------------------------------------------------- /server/wrappers/notebook.ts: -------------------------------------------------------------------------------- 1 | import type { EventHandlerRequest, H3Event } from 'h3' 2 | import { access, constants } from 'node:fs/promises' 3 | import { join, resolve } from 'node:path' 4 | import { notesPath } from '~/server/folder' 5 | import type { APIError } from '~/types/result' 6 | 7 | type EventHandlerWithNotebook<T extends EventHandlerRequest, D> = ( 8 | event: H3Event<T>, 9 | notebook: string[], 10 | fullPath: string, 11 | parentFolder: string, 12 | name: string | undefined 13 | ) => Promise<D> 14 | 15 | export function defineEventHandlerWithNotebook<T extends EventHandlerRequest, D>( 16 | handler: EventHandlerWithNotebook<T, D>, 17 | options?: { notebookCheck: boolean } 18 | ) { 19 | return defineEventHandler(async (event) => { 20 | // Decode the path and then remove characters we cannot have 21 | const params = decodeURIComponent(event.context.params?.path ?? '') 22 | const notebooks = params 23 | .split('/') 24 | .map((p) => p.replace(/[\\/:*?"<>|.]/g, '')) 25 | .filter(Boolean) // Removes empty strings 26 | 27 | // Construct paths 28 | const fullPath = join(notesPath, ...notebooks) 29 | const targetFolder = resolve(fullPath) 30 | 31 | // Check OS path length limitations 32 | const isWindows = process.platform === 'win32' 33 | const maxPathLength = isWindows ? 259 : 4095 // Windows MAX_PATH (260 incl. null) vs Linux/macOS PATH_MAX (4096) 34 | 35 | if (fullPath.length > maxPathLength) { 36 | throw createError({ 37 | statusCode: 400, 38 | statusMessage: 'Bad Request', 39 | message: `Notebook name is too long. The full path exceeds the maximum allowed length of ${maxPathLength} characters.` 40 | }) 41 | } 42 | 43 | const parentFolderArray = notebooks.slice(0, -1) ?? [] 44 | const parentFolder = join(notesPath, ...parentFolderArray) 45 | const name = notebooks.at(-1) 46 | // This is for a new notebook, we can bail early 47 | if (options?.notebookCheck === false) return await handler(event, notebooks, fullPath, parentFolder, name) 48 | 49 | // Security checks 50 | if (!targetFolder.startsWith(resolve(notesPath))) { 51 | throw createError({ 52 | statusCode: 400, 53 | statusMessage: 'Bad Request', 54 | message: 'Invalid notebook path' 55 | }) 56 | } 57 | try { 58 | // Check if notebook exists 59 | await access(targetFolder, constants.R_OK | constants.W_OK) // Make sure its readable and writable 60 | } catch { 61 | throw createError({ 62 | statusCode: 404, 63 | statusMessage: 'Not Found', 64 | message: `Notebook "${notebooks.join(' > ')}" does not exist` 65 | }) 66 | } 67 | try { 68 | return await handler(event, notebooks, fullPath, parentFolder, name) 69 | } catch (error) { 70 | console.log(event, error) 71 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 72 | throw createError({ 73 | statusCode: 404, 74 | statusMessage: 'Not Found', 75 | message: 'Note or notebook does not exist' 76 | }) 77 | } else if (error instanceof URIError) { 78 | throw createError({ 79 | statusCode: 400, 80 | statusMessage: 'Bad Request', 81 | message: 'Invalid URL encoding.' 82 | }) 83 | } else if (error instanceof Error && 'statusCode' in error) { 84 | const err = error as APIError 85 | throw createError({ 86 | statusCode: err.statusCode ?? 500, 87 | statusMessage: err.statusMessage ?? 'Internal Server Error', 88 | message: err.message ?? 'An unexpected error occurred' 89 | }) 90 | } else { 91 | throw createError({ 92 | statusCode: 500, 93 | statusMessage: 'Internal Server Error', 94 | message: 'An unexpected error occurred' 95 | }) 96 | } 97 | } 98 | }) 99 | } 100 | -------------------------------------------------------------------------------- /server/wrappers/search.ts: -------------------------------------------------------------------------------- 1 | import type { ExecSyncOptions } from 'node:child_process' 2 | import type { EventHandlerRequest, H3Event } from 'h3' 3 | import { execSync } from 'node:child_process' 4 | import { resolve } from 'node:path' 5 | import { platform } from 'node:os' 6 | import escape from 'shell-escape' 7 | import type { SearchResult } from '~/types/notebook' 8 | import { notesPath } from '~/server/folder' 9 | 10 | type EventHandlerWithSearch<T extends EventHandlerRequest, D> = ( 11 | event: H3Event<T>, 12 | searchResults: SearchResult[] 13 | ) => Promise<D> 14 | 15 | export function defineEventHandlerWithSearch<T extends EventHandlerRequest, D>(handler: EventHandlerWithSearch<T, D>) { 16 | return defineEventHandler(async (event) => { 17 | const fullPath = resolve(notesPath) 18 | const { q: rawQuery } = getQuery(event) 19 | 20 | if (!rawQuery || typeof rawQuery !== 'string') { 21 | throw createError({ statusCode: 400, message: 'Missing query.' }) 22 | } 23 | 24 | const results: SearchResult[] = [] 25 | const queryLower = rawQuery.toLowerCase() 26 | 27 | // Prepare command based on platform: 28 | let command: string 29 | const execOptions: ExecSyncOptions = { encoding: 'utf-8', maxBuffer: 1024 * 1024 * 10 } 30 | 31 | if (platform() === 'win32') { 32 | // Windows: Use PowerShell's Get-ChildItem to list folders and markdown files 33 | // We enclose fullPath in single quotes and escape it properly. 34 | // command = `Get-ChildItem -Path '${fullPath}' -Recurse | ForEach-Object { $_.FullName }` 35 | command = `Get-ChildItem -Path '${fullPath}' -Recurse | ForEach-Object { if ($_.PSIsContainer) { "dir:$($_.FullName)" } else { "file:$($_.FullName)" } }` 36 | execOptions.shell = 'powershell.exe' 37 | } else { 38 | // Unix (Linux/macOS): Use find to search for directories (-type d) 39 | const searchPath = escape([fullPath]) 40 | command = `find ${searchPath} -type d -printf "dir:%p\n" -o -type f -printf "file:%p\n"` 41 | // command = `find ${searchPath} \\( -type d -o -type f \\)` 42 | } 43 | 44 | try { 45 | const output = execSync(command, execOptions) as string 46 | const lines = output.split('\n').filter((line) => line.trim() !== '') 47 | 48 | for (const line of lines) { 49 | const [type, full] = line.split(':', 2) 50 | if (!type || !full) continue 51 | 52 | const relativePath = full.replace(fullPath, '').split(/[/\\]/).filter(Boolean) 53 | if (relativePath.length === 0) continue 54 | 55 | const baseName = relativePath[relativePath.length - 1] 56 | if (!baseName.toLowerCase().includes(queryLower)) continue 57 | 58 | const isFolder = type === 'dir' 59 | 60 | results.push({ 61 | notebook: relativePath.slice(0, -1), 62 | name: baseName, 63 | matchType: isFolder ? 'folder' : 'note', 64 | snippet: `${isFolder ? 'Folder' : 'File'} name contains "${rawQuery}"`, 65 | score: isFolder ? 1 : 2 66 | }) 67 | } 68 | } catch (error) { 69 | console.error(error) 70 | throw createError({ 71 | statusCode: 500, 72 | statusMessage: 'Internal Server Error', 73 | data: error, 74 | message: 'Unable to search. Check console for details.' 75 | }) 76 | } 77 | 78 | // Deduplicate and sort by score (descending), and limit to MAX_RESULTS (here, 5) 79 | const searchResults: SearchResult[] = Array.from(new Set(results.map((r) => JSON.stringify(r)))) 80 | .map((r) => JSON.parse(r) as SearchResult) 81 | .sort((a, b) => b.score - a.score) 82 | .slice(0, 5) 83 | 84 | return await handler(event, searchResults) 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /stores/auth.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import type { FetchError } from 'ofetch' 3 | import type { Result } from '~/types/result' 4 | 5 | export const useAuthStore = defineStore('auth', () => { 6 | const isLoggingIn: Ref<boolean> = ref(false) 7 | const error: Ref<string | null> = ref(null) 8 | const isLoggedIn = ref(false) 9 | 10 | const verify = async (): Promise<Result<boolean>> => { 11 | try { 12 | const verify = await $fetch<Result<boolean>>('/api/auth/verify') 13 | if (verify.success) isLoggedIn.value = verify.data 14 | return verify 15 | } catch (err) { 16 | console.log(err) 17 | error.value = (err as FetchError).data.message 18 | return { 19 | success: false, 20 | message: error.value ?? '' 21 | } 22 | } 23 | } 24 | 25 | const login = async (secretKey: string | null) => { 26 | if (!secretKey) { 27 | error.value = 'Secret key is required' 28 | return 29 | } 30 | isLoggingIn.value = true 31 | error.value = null // Clear previous errors 32 | 33 | try { 34 | await $fetch.raw(`/api/auth/login`, { 35 | method: 'POST', 36 | body: { key: secretKey } 37 | }) 38 | 39 | isLoggedIn.value = true 40 | 41 | navigateTo('/') 42 | } catch (err) { 43 | error.value = (err as FetchError).data?.message ?? 'Login failed' 44 | localStorage.setItem('isLoggedIn', 'false') 45 | } finally { 46 | isLoggingIn.value = false 47 | } 48 | } 49 | 50 | const logout = async () => { 51 | await $fetch('/api/auth/logout') 52 | isLoggedIn.value = false 53 | navigateTo('/login') 54 | } 55 | 56 | const checkAuth = async () => { 57 | try { 58 | if (isLoggedIn.value) return true 59 | 60 | const verified = await verify() 61 | 62 | if (verified.success) { 63 | return verified.data 64 | } 65 | } catch { 66 | return false 67 | } 68 | } 69 | 70 | watch(isLoggedIn, (newVal) => { 71 | localStorage.setItem('isLoggedIn', newVal.toString()) 72 | }) 73 | 74 | return { 75 | isLoggingIn, 76 | error, 77 | login, 78 | logout, 79 | checkAuth 80 | } 81 | }) 82 | -------------------------------------------------------------------------------- /stores/settings.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import type { InsertSetting } from '~/server/db/schema' 3 | import type { Result } from '~/types/result' 4 | 5 | export const useSettingsStore = defineStore('settings', () => { 6 | const { $settings } = useNuxtApp() 7 | const settingSetError: Ref<null | string> = ref(null) 8 | const error: Ref<string | null> = ref($settings.error ? ($settings.error ?? 'Unknown error') : null) 9 | const settings = reactive({ 10 | isDense: $settings.data.get('isDense') === 'true', 11 | isParagraphSpaced: $settings.data.get('isParagraphSpaced') 12 | ? $settings.data.get('isParagraphSpaced') === 'true' 13 | : true 14 | }) 15 | 16 | const setSetting = async (insertSetting: InsertSetting): Promise<Result<null>> => { 17 | const newSetting: InsertSetting = { 18 | setting: insertSetting.setting, 19 | value: insertSetting.value 20 | } 21 | 22 | const resp = await $fetch('/api/settings', { method: 'POST', body: newSetting }) 23 | if (!resp.success) settingSetError.value = resp.message 24 | 25 | return resp 26 | } 27 | 28 | /** 29 | * Dense list 30 | */ 31 | const setDenseMode = async () => { 32 | const setting: InsertSetting = { 33 | setting: 'isDense', 34 | value: settings.isDense.toString() 35 | } 36 | setSetting(setting) 37 | } 38 | const toggleDenseMode = () => (settings.isDense = !settings.isDense) 39 | 40 | /** 41 | * Paragraph spacing 42 | */ 43 | const setParagraphSpacing = async () => { 44 | const setting: InsertSetting = { 45 | setting: 'isParagraphSpaced', 46 | value: settings.isParagraphSpaced.toString() 47 | } 48 | setSetting(setting) 49 | } 50 | const toggleParagraphSpacing = () => (settings.isParagraphSpaced = !settings.isParagraphSpaced) 51 | 52 | watch( 53 | () => settings.isDense, 54 | () => setDenseMode() 55 | ) 56 | 57 | watch( 58 | () => settings.isParagraphSpaced, 59 | () => setParagraphSpacing() 60 | ) 61 | 62 | return { 63 | toggleDenseMode, 64 | settings, 65 | error, 66 | settingSetError, 67 | toggleParagraphSpacing 68 | } 69 | }) 70 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | 3 | export default { 4 | content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}', './pages/**/*.vue', './components/**/*.vue', './layouts/*.vue'], 5 | darkMode: 'class', 6 | theme: { 7 | screens: { 8 | 'xs': '480px', // Extra small devices 9 | 'sm': '640px', // Small devices 10 | 'md': '768px', // Medium devices 11 | 'lg': '1024px', // Large devices 12 | 'xl': '1280px', // Extra large devices 13 | '2xl': '1536px', // 2X large devices 14 | '3xl': '1920px', // Custom breakpoint 15 | }, 16 | extend: { 17 | boxShadow: { 18 | 'top-md': '0 -1px 0 -1px #e0e3e8,0 -1px 6px 0 rgba(69,98,155,.12)', 19 | }, 20 | colors: { 21 | accent: '#306cfe', 22 | 'accent-hover': '#0035b3' 23 | }, 24 | height: { 25 | svw: '100svw' 26 | }, 27 | keyframes: { 28 | linspin: { 29 | '100%': { transform: 'rotate(360deg)' } 30 | }, 31 | easespin: { 32 | '12.5%': { transform: 'rotate(135deg)' }, 33 | '25%': { transform: 'rotate(270deg)' }, 34 | '37.5%': { transform: 'rotate(405deg)' }, 35 | '50%': { transform: 'rotate(540deg)' }, 36 | '62.5%': { transform: 'rotate(675deg)' }, 37 | '75%': { transform: 'rotate(810deg)' }, 38 | '87.5%': { transform: 'rotate(945deg)' }, 39 | '100%': { transform: 'rotate(1080deg)' } 40 | }, 41 | 'left-spin': { 42 | '0%': { transform: 'rotate(130deg)' }, 43 | '50%': { transform: 'rotate(-5deg)' }, 44 | '100%': { transform: 'rotate(130deg)' } 45 | }, 46 | 'right-spin': { 47 | '0%': { transform: 'rotate(-130deg)' }, 48 | '50%': { transform: 'rotate(5deg)' }, 49 | '100%': { transform: 'rotate(-130deg)' } 50 | }, 51 | rotating: { 52 | '0%, 100%': { transform: 'rotate(360deg)' }, 53 | '50%': { transform: 'rotate(0deg)' } 54 | }, 55 | topbottom: { 56 | '0%, 100%': { transform: 'translate3d(0, -100%, 0)' }, 57 | '50%': { transform: 'translate3d(0, 0, 0)' } 58 | }, 59 | bottomtop: { 60 | '0%, 100%': { transform: 'translate3d(0, 0, 0)' }, 61 | '50%': { transform: 'translate3d(0, -100%, 0)' } 62 | }, 63 | overlayShow: { 64 | from: { opacity: 0 }, 65 | to: { opacity: 1 } 66 | }, 67 | popIn: { 68 | from: { opacity: 0, transform: 'scale(0.96)' }, 69 | to: { opacity: 1, transform: 'scale(1)' } 70 | }, 71 | contentShow: { 72 | from: { opacity: 0, transform: 'translate(-50%, -48%) scale(0.96)' }, 73 | to: { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' } 74 | }, 75 | slideDownAndFade: { 76 | from: { opacity: 0, transform: 'translateY(-2px)' }, 77 | to: { opacity: 1, transform: 'translateY(0)' } 78 | }, 79 | slideLeftAndFade: { 80 | from: { opacity: 0, transform: 'translateX(2px)' }, 81 | to: { opacity: 1, transform: 'translateX(0)' } 82 | }, 83 | slideUpAndFade: { 84 | from: { opacity: 0, transform: 'translateY(2px)' }, 85 | to: { opacity: 1, transform: 'translateY(0)' } 86 | }, 87 | slideRightAndFade: { 88 | from: { opacity: 0, transform: 'translateX(-2px)' }, 89 | to: { opacity: 1, transform: 'translateX(0)' } 90 | }, 91 | slideDown: { 92 | from: { height: 0 }, 93 | to: { height: 'var(--radix-accordion-content-height)' } 94 | }, 95 | slideUp: { 96 | from: { height: 'var(--radix-accordion-content-height)' }, 97 | to: { height: 0 } 98 | } 99 | }, 100 | animation: { 101 | linspin: 'linspin 1568.2353ms linear infinite', 102 | easespin: 'easespin 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both', 103 | 'left-spin': 'left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both', 104 | 'right-spin': 'right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both', 105 | 'ping-once': 'ping 5s cubic-bezier(0, 0, 0.2, 1)', 106 | rotating: 'rotating 30s linear infinite', 107 | topbottom: 'topbottom 60s infinite alternate linear', 108 | bottomtop: 'bottomtop 60s infinite alternate linear', 109 | 'spin-1.5': 'spin 1.5s linear infinite', 110 | 'spin-2': 'spin 2s linear infinite', 111 | 'spin-3': 'spin 3s linear infinite', 112 | overlayShow: 'overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1)', 113 | contentShow: 'contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1)', 114 | popIn: 'popIn 150ms cubic-bezier(0.16, 1, 0.3, 1)', 115 | slideDownAndFade: 'slideDownAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)', 116 | slideLeftAndFade: 'slideLeftAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)', 117 | slideUpAndFade: 'slideUpAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)', 118 | slideRightAndFade: 'slideRightAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)', 119 | slideDown: 'slideDown 300ms cubic-bezier(0.87, 0, 0.13, 1)', 120 | slideUp: 'slideUp 300ms cubic-bezier(0.87, 0, 0.13, 1)' 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/server/api/attachment.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { beforeAll, describe, expect, it } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils' 4 | import { getAuthCookie } from '~/tests/setup' 5 | import { access, constants, mkdir, writeFile } from 'node:fs/promises' 6 | import { join } from 'node:path' 7 | import { notesPath, uploadPath } from '~/server/folder' 8 | 9 | let authCookie = '' 10 | let apiFilePath = '' 11 | describe('Attachments upload and view', async () => { 12 | await setup({ 13 | rootDir: fileURLToPath(new URL('..', import.meta.url)), 14 | server: true 15 | }) 16 | 17 | beforeAll(async () => { 18 | authCookie = await getAuthCookie() 19 | }) 20 | 21 | it('Uploads a file and responds with the absolute path', async () => { 22 | const blob = new Blob([`Test Upload`], { type: 'text' }) 23 | 24 | const formData = new FormData() 25 | formData.append('file', blob, 'upload.txt') // The file to upload 26 | formData.append('path', '/notes/test') // The filename to use when saving 27 | 28 | const response = await $fetch<string>('/api/attachment', { 29 | method: 'POST', 30 | body: formData, 31 | headers: { Cookie: authCookie } 32 | }) 33 | 34 | const regex = /\/api\/attachment\/[a-p]{21}_upload.txt/g 35 | 36 | apiFilePath = response 37 | 38 | expect(response.match(regex)?.length).toBe(1) 39 | }) 40 | 41 | it('Checks if uploaded file was created', async () => { 42 | const fileName = apiFilePath.split('/').at(-1) ?? '' 43 | await expect(access(join(uploadPath, 'attachments', fileName))).resolves.not.toThrow() 44 | }) 45 | 46 | it('Retrieves uploaded file', async () => { 47 | const response = await $fetch(apiFilePath, { 48 | method: 'GET', 49 | headers: { Cookie: authCookie } 50 | }) 51 | 52 | expect(response).toEqual('Test Upload') 53 | }) 54 | 55 | it('Deletes attachment on note update where attachment is removed', async () => { 56 | const fileName = `upload.txt` 57 | 58 | //Create notebook 59 | const fullPath = join(notesPath, 'TestUpload') 60 | await mkdir(fullPath) 61 | 62 | // create attachments 63 | const attachmentBlob = new Blob([`Test Upload`], { type: 'text' }) 64 | const attachmentFormData = new FormData() 65 | attachmentFormData.append('file', attachmentBlob, fileName) // The file to upload 66 | attachmentFormData.append('path', 'TestUpload/TestUpload') // The filename to use when saving 67 | const resp = await $fetch<string>('/api/attachment', { 68 | method: 'POST', 69 | body: attachmentFormData, 70 | headers: { Cookie: authCookie } 71 | }) 72 | 73 | //Create note 74 | const notePath = join(fullPath, 'TestUpload.md') 75 | await writeFile(notePath, [`# Test Note ::file{href="${resp}" title="upload.txt"}`]) 76 | 77 | const uploadedFileName = resp.split('/').at(-1) ?? '' 78 | 79 | //Send note update 80 | const blob = new Blob(['# Updated'], { type: 'text/markdown' }) 81 | const formData = new FormData() 82 | formData.append('file', blob, `TestUpload.md`) // The file to upload 83 | formData.append('filename', `TestUpload.md`) // The filename to use when saving 84 | 85 | await $fetch('/api/note/TestUpload/TestUpload.md', { 86 | method: 'PATCH', 87 | body: formData, 88 | headers: { Cookie: authCookie } 89 | }) 90 | 91 | // Delay for 2 seconds - just to make sure its cleared the queue 92 | await new Promise((resolve) => setTimeout(resolve, 2000)) 93 | 94 | const filePath = join(uploadPath, 'attachments', uploadedFileName) 95 | await expect(access(filePath, constants.R_OK | constants.W_OK)).rejects.toThrow() 96 | }) 97 | 98 | it('Deletes all attachments belonging to a deleted note', async () => { 99 | const fileName = 'upload.txt' 100 | 101 | //Create notebook 102 | const fullPath = join(notesPath, 'TestUploadAll') 103 | await mkdir(fullPath) 104 | 105 | // create attachments 106 | const attachmentBlob = new Blob([`Test Upload`], { type: 'text' }) 107 | const attachmentFormData = new FormData() 108 | attachmentFormData.append('file', attachmentBlob, fileName) // The file to upload 109 | attachmentFormData.append('path', 'TestUploadAll/TestUploadAll') // The filename to use when saving 110 | 111 | const resp = await $fetch<string>('/api/attachment', { 112 | method: 'POST', 113 | body: attachmentFormData, 114 | headers: { Cookie: authCookie } 115 | }) 116 | 117 | //Create note 118 | const notePath = join(fullPath, 'TestUploadAll.md') 119 | await writeFile(notePath, [`# Test Note ::file{href="${resp}" title="upload.txt"}`]) 120 | 121 | const uploadedFileName = resp.split('/').at(-1) ?? '' 122 | 123 | await $fetch('/api/note/TestUploadAll/TestUploadAll.md', { 124 | method: 'DELETE', 125 | headers: { Cookie: authCookie } 126 | }) 127 | 128 | // Delay for 2 seconds - just to make sure its cleared the queue 129 | await new Promise((resolve) => setTimeout(resolve, 2000)) 130 | 131 | const filePath = join(uploadPath, 'attachments', uploadedFileName) 132 | await expect(access(filePath, constants.R_OK | constants.W_OK)).rejects.toThrow() 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /tests/server/api/health.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { describe, expect, it } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils' 4 | describe('Health check', async () => { 5 | await setup({ 6 | rootDir: fileURLToPath(new URL('..', import.meta.url)), 7 | server: true 8 | }) 9 | 10 | it('Response expected health check', async () => { 11 | const response = await $fetch('/api/health') 12 | const resp = { 13 | status: 'OK', 14 | message: 'Service is running', 15 | warnings: [ 16 | 'Secret key should be changed from the default.', 17 | 'Storage location is not set, this could result in loss of notes.', 18 | 'Uploads location is not set, this could result in loss of uploads.', 19 | 'Config location is not set, this could result in loss of settings and shared notes.' 20 | ] 21 | } 22 | 23 | expect(response).toEqual(expect.objectContaining(resp)) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /tests/server/api/search.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { beforeAll, describe, expect, it } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils' 4 | import { getAuthCookie } from '~/tests/setup' 5 | import { join } from 'node:path' 6 | import { notesPath } from '~/server/folder' 7 | import { mkdir, writeFile } from 'node:fs/promises' 8 | import { nanoid } from 'nanoid' 9 | import type { SearchResult } from '~/types/notebook' 10 | describe('Health check', async () => { 11 | await setup({ 12 | rootDir: fileURLToPath(new URL('..', import.meta.url)), 13 | server: true 14 | }) 15 | 16 | let authCookie = '' 17 | const nanoidString = nanoid() 18 | const notebookName = nanoid() 19 | const fileName = nanoid() 20 | 21 | beforeAll(async () => { 22 | authCookie = await getAuthCookie() 23 | const fullPath = join(notesPath, notebookName) 24 | await mkdir(fullPath) 25 | 26 | // create file 27 | let fileContent = Buffer.from('') 28 | fileContent = Buffer.from(nanoidString) 29 | const filePath = join(notesPath, notebookName, `${fileName}.md`) 30 | await writeFile(filePath, fileContent) 31 | }) 32 | 33 | it('Response matches content search', async () => { 34 | const response = await $fetch('/api/search', { 35 | query: { q: nanoidString }, 36 | headers: { Cookie: authCookie } 37 | }) 38 | const resp: SearchResult[] = [ 39 | { 40 | notebook: [notebookName], 41 | name: fileName, 42 | matchType: 'content', 43 | snippet: nanoidString, 44 | score: 3 45 | } 46 | ] 47 | 48 | expect(response).toEqual(expect.arrayContaining(resp)) 49 | }) 50 | 51 | it('Response matches note name', async () => { 52 | const response = await $fetch('/api/search', { 53 | query: { q: fileName }, 54 | headers: { Cookie: authCookie } 55 | }) 56 | const resp: SearchResult[] = [ 57 | { 58 | notebook: [notebookName], 59 | name: fileName, 60 | matchType: 'note', 61 | snippet: `Note name contains "${fileName}"`, 62 | score: 2 63 | } 64 | ] 65 | 66 | expect(response).toEqual(expect.arrayContaining(resp)) 67 | }) 68 | 69 | it('Response matches notebook name', async () => { 70 | const response = await $fetch('/api/search', { 71 | query: { q: notebookName }, 72 | headers: { Cookie: authCookie } 73 | }) 74 | const resp: SearchResult[] = [ 75 | { 76 | notebook: [notebookName], 77 | name: notebookName, 78 | matchType: 'folder', 79 | snippet: `Notebook name contains "${notebookName}"`, 80 | score: 1 81 | } 82 | ] 83 | 84 | expect(response).toEqual(expect.arrayContaining(resp)) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /tests/server/api/settings.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { beforeAll, describe, expect, it } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils' 4 | import { getAuthCookie } from '~/tests/setup' 5 | import { nanoid } from 'nanoid' 6 | import type { InsertSetting, SelectSetting } from '~/server/db/schema' 7 | import type { Result } from '~/types/result' 8 | 9 | describe('Settings API', async () => { 10 | await setup({ 11 | rootDir: fileURLToPath(new URL('..', import.meta.url)), // Assuming this path is correct as per search.test.ts 12 | server: true 13 | }) 14 | 15 | let authCookie = '' 16 | 17 | // Define unique keys and values for settings to be used in tests 18 | const settingKey1 = nanoid() 19 | const initialValue1 = nanoid() 20 | const updatedValue1 = nanoid() 21 | 22 | const settingKey2 = nanoid() 23 | const value2 = nanoid() 24 | 25 | beforeAll(async () => { 26 | authCookie = await getAuthCookie() 27 | }) 28 | 29 | describe('POST /api/settings', () => { 30 | it('should create a new setting', async () => { 31 | const newSetting: InsertSetting = { 32 | setting: settingKey1, 33 | value: initialValue1 34 | } 35 | 36 | const response: Result<null> = await $fetch('/api/settings', { 37 | method: 'POST', 38 | body: newSetting, 39 | headers: { Cookie: authCookie } 40 | }) 41 | 42 | expect(response.success).toBe(true) 43 | if (response.success) { 44 | expect(response.data).toBeNull() 45 | } 46 | }) 47 | 48 | it('should update an existing setting', async () => { 49 | // This test relies on settingKey1 being created in the previous test. 50 | // The endpoint uses onConflictDoUpdate, so it will update if settingKey1 exists. 51 | const updatedSetting: InsertSetting = { 52 | setting: settingKey1, 53 | value: updatedValue1 54 | } 55 | 56 | const response: Result<null> = await $fetch('/api/settings', { 57 | method: 'POST', 58 | body: updatedSetting, 59 | headers: { Cookie: authCookie } 60 | }) 61 | 62 | expect(response.success).toBe(true) 63 | if (response.success) { 64 | expect(response.data).toBeNull() 65 | } 66 | }) 67 | }) 68 | 69 | describe('GET /api/settings/all', () => { 70 | it('should retrieve all settings, including created and updated ones', async () => { 71 | // Create a second setting to ensure we are testing retrieval of multiple items 72 | const newSettingPayload: InsertSetting = { 73 | setting: settingKey2, 74 | value: value2 75 | } 76 | const postResponse: Result<null> = await $fetch('/api/settings', { 77 | method: 'POST', 78 | body: newSettingPayload, 79 | headers: { Cookie: authCookie } 80 | }) 81 | expect(postResponse.success).toBe(true) // Ensure creation was successful 82 | 83 | // Fetch all settings 84 | const response: Result<SelectSetting[]> = await $fetch('/api/settings/all', { 85 | headers: { Cookie: authCookie } 86 | }) 87 | 88 | expect(response.success).toBe(true) 89 | if (response.success) { 90 | expect(response.data).toBeInstanceOf(Array) 91 | // Check for the first setting (which should have been updated) 92 | const retrievedSetting1 = response.data?.find((s) => s.setting === settingKey1) 93 | expect(retrievedSetting1).toBeDefined() 94 | // Assuming SelectSetting includes id, setting, value 95 | expect(retrievedSetting1?.value).toBe(updatedValue1) 96 | 97 | // Check for the second setting 98 | const retrievedSetting2 = response.data?.find((s) => s.setting === settingKey2) 99 | expect(retrievedSetting2).toBeDefined() 100 | expect(retrievedSetting2?.value).toBe(value2) 101 | 102 | // Optional: Check if id is present and is a number (if your schema defines it) 103 | expect(typeof retrievedSetting1?.id).toBe('number') 104 | expect(typeof retrievedSetting2?.id).toBe('number') 105 | } 106 | }) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import jwt from 'jsonwebtoken' 3 | import { notesPath, uploadPath } from '~/server/folder' 4 | import { readdir, rm, unlink, lstat } from 'node:fs/promises' 5 | 6 | let authCookie: string 7 | 8 | export async function authenticate() { 9 | const token = jwt.sign({ app: 'nanote' }, 'nanote', { expiresIn: '7d', audience: 'authorized' }) 10 | authCookie = `token=${token}` 11 | } 12 | 13 | export async function getAuthCookie() { 14 | if (!authCookie) await authenticate() 15 | return authCookie 16 | } 17 | 18 | export async function emptyFolder(folderPath: string) { 19 | try { 20 | const files = await readdir(folderPath) 21 | for (const file of files) { 22 | const filePath = path.join(folderPath, file) 23 | const stat = await lstat(filePath) 24 | if (stat.isDirectory()) { 25 | await rm(filePath, { recursive: true, force: true }) 26 | } else { 27 | await unlink(filePath) 28 | } 29 | } 30 | console.log(`Emptied folder: ${folderPath}`) 31 | } catch (error) { 32 | console.error(`Error emptying folder: ${error}`) 33 | } 34 | } 35 | 36 | // Example usage: 37 | emptyFolder(notesPath) 38 | emptyFolder(uploadPath) 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /types/notebook.ts: -------------------------------------------------------------------------------- 1 | export type Notebook = { 2 | name: string 3 | createdAt: string 4 | updatedAt: string | null 5 | noteCount: number 6 | notebookCount: number 7 | notebooks: string[] 8 | contents?: NotebookContents 9 | path: string 10 | } 11 | 12 | export type Note = { 13 | name: string 14 | createdAt: string 15 | updatedAt: string | null 16 | notebook: string[] 17 | size?: number 18 | isMarkdown: boolean 19 | } 20 | 21 | export type NotebookContents = { 22 | path: string 23 | notebooks?: Record<string, Notebook> 24 | notes: Note[] 25 | pathArray: string[] 26 | } 27 | 28 | export type NoteResponse = { 29 | notebook: string[] 30 | note: string 31 | path: string 32 | createdAt: string 33 | updatedAt: string 34 | size: number 35 | originalFilename: string 36 | } 37 | 38 | export type RenameNotebook = { 39 | oldName: string 40 | path: string 41 | newName: string 42 | createdAt: string 43 | updatedAt: string 44 | notebooks: string[] 45 | } 46 | 47 | export type RenameNote = { 48 | oldName: string 49 | newName: string 50 | notebook: string[] 51 | createdAt: string 52 | updatedAt: string 53 | } 54 | 55 | export type DeleteNote = { 56 | name: string 57 | timestamp: string 58 | notebook: string[] 59 | deleted: boolean 60 | } 61 | 62 | export type DeleteNotebook = { 63 | timestamp: string 64 | notebook: string[] 65 | deleted: boolean 66 | } 67 | 68 | export type SavingState = 'pending' | 'saving' | 'success' | 'error' | 'idle' 69 | 70 | export type SearchResult = { 71 | notebook: string[] 72 | name: string | null 73 | matchType: 'folder' | 'note' | 'content' 74 | snippet: string 75 | score: number 76 | } 77 | 78 | export type NotebookDisplay = 'main' | 'sidebar' | 'other' 79 | -------------------------------------------------------------------------------- /types/result.ts: -------------------------------------------------------------------------------- 1 | import type { H3Error } from 'h3' 2 | 3 | export type Result<T> = 4 | | { 5 | success: false 6 | message: string 7 | } 8 | | { 9 | success: true 10 | data: T 11 | } 12 | 13 | export type APIError = Partial<H3Error<unknown>> & { 14 | status?: number 15 | statusText?: string 16 | } 17 | -------------------------------------------------------------------------------- /types/shell-escape.d.ts: -------------------------------------------------------------------------------- 1 | // types/shell-escape.d.ts 2 | declare module 'shell-escape' { 3 | function shellEscape(args: string[]): string 4 | export = shellEscape 5 | } 6 | -------------------------------------------------------------------------------- /types/ui.ts: -------------------------------------------------------------------------------- 1 | export type Theme = 'danger' | 'warning' | 'info' | 'primary' 2 | -------------------------------------------------------------------------------- /types/upload.ts: -------------------------------------------------------------------------------- 1 | export type MultiPartData = { name?: string; data: Buffer<ArrayBufferLike> | string; filename?: string } 2 | -------------------------------------------------------------------------------- /utils/delay.ts: -------------------------------------------------------------------------------- 1 | export const waitforme = (millisec: number) => { 2 | return new Promise((resolve) => { 3 | setTimeout(() => { 4 | resolve('') 5 | }, millisec) 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /utils/downloader.ts: -------------------------------------------------------------------------------- 1 | export const downloadFile = (file: File): void => { 2 | // Create a link and set the URL using `createObjectURL` 3 | const link = document.createElement('a') 4 | link.style.display = 'none' 5 | link.href = URL.createObjectURL(file) 6 | link.download = file.name 7 | 8 | // It needs to be added to the DOM so it can be clicked 9 | document.body.appendChild(link) 10 | link.click() 11 | 12 | // To make this work on Firefox we need to wait a little while before removing it. 13 | setTimeout(() => { 14 | URL.revokeObjectURL(link.href) 15 | link.parentNode?.removeChild(link) 16 | }, 0) 17 | } 18 | -------------------------------------------------------------------------------- /utils/file-extension.ts: -------------------------------------------------------------------------------- 1 | export const getFileNameAndExtension = (filename: string) => { 2 | if (typeof filename !== 'string') { 3 | return { name: '', extension: '' } // Or throw an error, or return null 4 | } 5 | 6 | let name = filename // Default name is the full string 7 | let extension = '' // Default extension is empty 8 | 9 | const lastDotIndex = filename.lastIndexOf('.') 10 | 11 | // A dot exists and it's not the very first character (unless it's the only char like ".") 12 | // AND it's not the very last character of the string. 13 | if (lastDotIndex > -1 && lastDotIndex < filename.length - 1) { 14 | // This condition handles: 15 | // 1. Standard files: "file.txt" (lastDotIndex > 0) 16 | // name = "file", extension = "txt" 17 | // 2. Hidden files with extensions: ".config.json" (lastDotIndex > 0) 18 | // name = ".config", extension = "json" 19 | // 3. Hidden files treated as extensions: ".bashrc" (lastDotIndex == 0) 20 | // name = "", extension = "bashrc" 21 | name = filename.substring(0, lastDotIndex) 22 | extension = filename.substring(lastDotIndex + 1) 23 | } else if (lastDotIndex > -1 && lastDotIndex === filename.length - 1 && filename.length > 1) { 24 | // Handles filenames ending with a dot, but are not just "." 25 | // e.g., "archive." 26 | // name = "archive", extension = "" 27 | name = filename.substring(0, lastDotIndex) 28 | // extension remains "" 29 | } 30 | // Other cases not explicitly handled by the 'if' or 'else if': 31 | // 1. No dot: "myfile" -> name = "myfile", extension = "" (defaults are correct) 32 | // 2. Just a dot: "." -> name = ".", extension = "" (defaults are correct) 33 | // 3. Empty string: "" -> name = "", extension = "" (defaults are correct) 34 | 35 | return { name, extension } 36 | } 37 | -------------------------------------------------------------------------------- /utils/path-joiner.ts: -------------------------------------------------------------------------------- 1 | import type { Notebook, NotebookContents } from '~/types/notebook' 2 | 3 | export const notebookPathArrayJoiner = (notebook: Notebook) => 4 | [...notebook.notebooks.filter((path) => path !== ''), notebook.name].join('/') 5 | 6 | export const notePathArrayJoiner = (notebooks: string[]) => notebooks.filter((path) => path !== '').join('/') 7 | 8 | export const arraysEqual = (arr1: string[], arr2: string[]) => 9 | arr1.length === arr2.length && arr1.every((value, index) => value === arr2[index]) 10 | 11 | export const getNotebookByPathArray = (path: string[], contents: NotebookContents | null): Notebook | undefined => { 12 | if (!contents) return undefined 13 | let currentContents = contents 14 | let notebook: Notebook | undefined 15 | 16 | for (let i = 0; i < path.length; i++) { 17 | // If there are no notebooks at this level, we can't continue 18 | if (!currentContents.notebooks) return undefined 19 | 20 | notebook = currentContents.notebooks[path[i]] 21 | if (!notebook) return undefined 22 | 23 | // If this isn't the last element, set up for the next level 24 | if (i < path.length - 1) { 25 | if (!notebook.contents) return undefined 26 | currentContents = notebook.contents 27 | } 28 | } 29 | 30 | return notebook 31 | } 32 | -------------------------------------------------------------------------------- /utils/uploader.ts: -------------------------------------------------------------------------------- 1 | import type { Uploader } from '@milkdown/kit/plugin/upload' 2 | import type { Node } from '@milkdown/kit/prose/model' 3 | 4 | const onUpload = async (file: File, path: string): Promise<string> => { 5 | const formData = new FormData() 6 | formData.append('file', file) 7 | formData.append('path', path) 8 | 9 | const url = await $fetch<string>('/api/attachment', { 10 | method: 'POST', 11 | body: formData 12 | }) 13 | 14 | return url 15 | } 16 | 17 | const toCheckUploader = async (fileURL: string) => { 18 | const resp = await $fetch<string>('/api/attachment/check', { 19 | method: 'GET', 20 | query: { url: fileURL } 21 | }) 22 | 23 | return resp 24 | } 25 | 26 | const createUploader = () => { 27 | const uploader: Uploader = async (files, schema) => { 28 | const nodes: Node[] = await Promise.all( 29 | Array.from(files).map(async (file) => { 30 | const src = onUpload 31 | 32 | // Handle image files 33 | if (file.type.includes('image')) { 34 | return schema.nodes.image.createAndFill({ 35 | src, 36 | alt: file.name 37 | }) as Node 38 | } 39 | 40 | // Handle other files as attachment links 41 | const linkMark = schema.marks.link.create({ href: src }) 42 | const textNode = schema.text(file.name, [linkMark]) 43 | return schema.nodes.paragraph.create({}, textNode) 44 | }) 45 | ) 46 | 47 | return nodes.filter((node): node is Node => !!node) 48 | } 49 | 50 | return uploader 51 | } 52 | 53 | export { createUploader, onUpload, toCheckUploader } 54 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineVitestConfig } from '@nuxt/test-utils/config' 2 | 3 | export default defineVitestConfig({ 4 | test: { 5 | setupFiles: './tests/setup.ts' 6 | } 7 | }) 8 | --------------------------------------------------------------------------------