├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dockerignore ├── .github └── FUNDING.yaml ├── .gitignore ├── .htmlnanorc ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.experimental ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── client ├── App.vue ├── api.js ├── assets │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ └── fonts │ │ └── Poppins │ │ ├── OFL.txt │ │ ├── Poppins-Italic.ttf │ │ ├── Poppins-Regular.ttf │ │ ├── Poppins-SemiBold.ttf │ │ └── Poppins-SemiBoldItalic.ttf ├── classes.js ├── components │ ├── ConfirmModal.vue │ ├── CustomButton.vue │ ├── IconLabel.vue │ ├── LoadingIndicator.vue │ ├── Logo.vue │ ├── Modal.vue │ ├── PrimeMenu.vue │ ├── PrimeToast.vue │ ├── Tag.vue │ ├── TextInput.vue │ ├── Toggle.vue │ └── toastui │ │ ├── ToastEditor.vue │ │ ├── ToastViewer.vue │ │ ├── baseOptions.js │ │ ├── extendedAutolinks.js │ │ └── toastui-editor-overrides.scss ├── constants.js ├── globalStore.js ├── helpers.js ├── index.html ├── index.js ├── partials │ ├── NavBar.vue │ ├── SearchInput.vue │ └── SearchModal.vue ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── router.js ├── style.css ├── tokenStorage.js └── views │ ├── Home.vue │ ├── LogIn.vue │ ├── Note.vue │ └── SearchResults.vue ├── docs └── logo.svg ├── entrypoint.sh ├── healthcheck.sh ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.js ├── pyproject.toml ├── server ├── api_messages.py ├── attachments │ ├── base.py │ ├── file_system │ │ ├── __init__.py │ │ └── file_system.py │ └── models.py ├── auth │ ├── base.py │ ├── local │ │ ├── __init__.py │ │ └── local.py │ └── models.py ├── global_config.py ├── helpers.py ├── logger.py ├── main.py └── notes │ ├── base.py │ ├── file_system │ ├── __init__.py │ └── file_system.py │ └── models.py ├── tailwind.config.js └── vite.config.js /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim-bullseye 2 | 3 | # Install pipenv 4 | RUN pip install --no-cache-dir pipenv 5 | 6 | # Install curl & git 7 | RUN apt update && apt install -y \ 8 | curl \ 9 | git \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | # Install Node.js 13 | RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash \ 14 | && export NVM_DIR="$HOME/.nvm" \ 15 | && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" \ 16 | && nvm install 20 17 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile 3 | { 4 | "name": "Dev Container", 5 | "build": { 6 | // Sets the run context to one level up instead of the .devcontainer folder. 7 | "context": "..", 8 | // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. 9 | "dockerfile": "./Dockerfile" 10 | }, 11 | 12 | // Features to add to the dev container. More info: https://containers.dev/features. 13 | // "features": {}, 14 | 15 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 16 | "forwardPorts": [ 17 | 8080 18 | ], 19 | 20 | // Uncomment the next line to run commands after the container is created. 21 | // "postCreateCommand": "cat /etc/os-release", 22 | 23 | // Configure tool-specific properties. 24 | "customizations": { 25 | "vscode": { 26 | "extensions": [ 27 | "ms-python.python", 28 | "ms-azuretools.vscode-docker", 29 | "ms-python.black-formatter", 30 | "esbenp.prettier-vscode", 31 | "Vue.volar" 32 | ] 33 | } 34 | } 35 | 36 | // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. 37 | // "remoteUser": "devcontainer" 38 | } 39 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | ** 3 | 4 | # Include the following 5 | !.htmlnanorc 6 | !LICENSE 7 | !Pipfile 8 | !Pipfile.lock 9 | !client/** 10 | !entrypoint.sh 11 | !healthcheck.sh 12 | !package-lock.json 13 | !package.json 14 | !postcss.config.js 15 | !server/** 16 | !tailwind.config.js 17 | !vite.config.js 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yaml: -------------------------------------------------------------------------------- 1 | github: Dullage 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 88 | __pypackages__/ 89 | 90 | # Celery stuff 91 | celerybeat-schedule 92 | celerybeat.pid 93 | 94 | # SageMath parsed files 95 | *.sage.py 96 | 97 | # Environments 98 | .env 99 | .venv 100 | env/ 101 | venv/ 102 | ENV/ 103 | env.bak/ 104 | venv.bak/ 105 | 106 | # Spyder project settings 107 | .spyderproject 108 | .spyproject 109 | 110 | # Rope project settings 111 | .ropeproject 112 | 113 | # mkdocs documentation 114 | /site 115 | 116 | # mypy 117 | .mypy_cache/ 118 | .dmypy.json 119 | dmypy.json 120 | 121 | # Pyre type checker 122 | .pyre/ 123 | 124 | # pytype static type analyzer 125 | .pytype/ 126 | 127 | # Cython debug symbols 128 | cython_debug/ 129 | 130 | 131 | # Node 132 | 133 | # Logs 134 | logs 135 | npm-debug.log* 136 | yarn-debug.log* 137 | yarn-error.log* 138 | lerna-debug.log* 139 | .pnpm-debug.log* 140 | 141 | # Diagnostic reports (https://nodejs.org/api/report.html) 142 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 143 | 144 | # Runtime data 145 | pids 146 | *.pid 147 | *.seed 148 | *.pid.lock 149 | 150 | # Directory for instrumented libs generated by jscoverage/JSCover 151 | lib-cov 152 | 153 | # Coverage directory used by tools like istanbul 154 | coverage 155 | *.lcov 156 | 157 | # nyc test coverage 158 | .nyc_output 159 | 160 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins 161 | #storing-task-files) 162 | .grunt 163 | 164 | # Bower dependency directory (https://bower.io/) 165 | bower_components 166 | 167 | # node-waf configuration 168 | .lock-wscript 169 | 170 | # Compiled binary addons (https://nodejs.org/api/addons.html) 171 | build/Release 172 | 173 | # Dependency directories 174 | node_modules/ 175 | jspm_packages/ 176 | 177 | # Snowpack dependency directory (https://snowpack.dev/) 178 | web_modules/ 179 | 180 | # TypeScript cache 181 | *.tsbuildinfo 182 | 183 | # Optional npm cache directory 184 | .npm 185 | 186 | # Optional eslint cache 187 | .eslintcache 188 | 189 | # Microbundle cache 190 | .rpt2_cache/ 191 | .rts2_cache_cjs/ 192 | .rts2_cache_es/ 193 | .rts2_cache_umd/ 194 | 195 | # Optional REPL history 196 | .node_repl_history 197 | 198 | # Output of 'npm pack' 199 | *.tgz 200 | 201 | # Yarn Integrity file 202 | .yarn-integrity 203 | 204 | # dotenv environment variables file 205 | .env.test 206 | .env.production 207 | 208 | # parcel-bundler cache (https://parceljs.org/) 209 | .parcel-cache 210 | 211 | # Next.js build output 212 | .next 213 | out 214 | 215 | # Nuxt.js build / generate output 216 | .nuxt 217 | dist 218 | 219 | # Gatsby files 220 | .cache/ 221 | 222 | # vuepress build output 223 | .vuepress/dist 224 | 225 | # Serverless directories 226 | .serverless/ 227 | 228 | # FuseBox cache 229 | .fusebox/ 230 | 231 | # DynamoDB Local files 232 | .dynamodb/ 233 | 234 | # TernJS port file 235 | .tern-port 236 | 237 | # Stores VSCode versions used for testing VSCode extensions 238 | .vscode-test 239 | 240 | # yarn v2 241 | .yarn/cache 242 | .yarn/unplugged 243 | .yarn/build-state.yml 244 | .yarn/install-state.gz 245 | .pnp.* 246 | 247 | # Custom 248 | .vscode/ 249 | data/ 250 | /notes/ 251 | -------------------------------------------------------------------------------- /.htmlnanorc: -------------------------------------------------------------------------------- 1 | { 2 | "minifySvg": false 3 | } 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to flatnotes 2 | 3 | ## Bug Fixes 4 | 5 | If you spot something not quite right in flatnotes and have the skills to fix it, then I'd welcome the pull request. If the fix requires a large change, then it would be best to open an issue first to discuss. 6 | 7 | ## New Features 8 | 9 | If you're interested in adding a new feature to flatnotes, then please open an issue first to discuss the idea. This will help to ensure that the feature is a good fit for the project and that you're not wasting your time. Whilst I'm keen to improve flatnotes, I'm committed to keeping it simple and focused, which will mean saying "no" more than I say "yes". 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BUILD_DIR=/build 2 | 3 | # Build Container 4 | FROM --platform=$BUILDPLATFORM node:20-alpine AS build 5 | 6 | ARG BUILD_DIR 7 | 8 | RUN mkdir ${BUILD_DIR} 9 | WORKDIR ${BUILD_DIR} 10 | 11 | COPY .htmlnanorc \ 12 | package.json \ 13 | package-lock.json \ 14 | postcss.config.js \ 15 | tailwind.config.js \ 16 | vite.config.js \ 17 | ./ 18 | 19 | RUN npm ci 20 | 21 | COPY client ./client 22 | RUN npm run build 23 | 24 | # Runtime Container 25 | FROM python:3.11-slim-bullseye 26 | 27 | ARG BUILD_DIR 28 | 29 | ENV PUID=1000 30 | ENV PGID=1000 31 | ENV EXEC_TOOL=gosu 32 | ENV FLATNOTES_HOST=0.0.0.0 33 | ENV FLATNOTES_PORT=8080 34 | 35 | ENV APP_PATH=/app 36 | ENV FLATNOTES_PATH=/data 37 | 38 | RUN mkdir -p ${APP_PATH} 39 | RUN mkdir -p ${FLATNOTES_PATH} 40 | 41 | RUN apt update && apt install -y \ 42 | curl \ 43 | gosu \ 44 | && rm -rf /var/lib/apt/lists/* 45 | 46 | RUN pip install --no-cache-dir pipenv 47 | 48 | WORKDIR ${APP_PATH} 49 | 50 | COPY LICENSE Pipfile Pipfile.lock ./ 51 | RUN pipenv install --deploy --ignore-pipfile --system && \ 52 | pipenv --clear 53 | 54 | COPY server ./server 55 | COPY --from=build --chmod=777 ${BUILD_DIR}/client/dist ./client/dist 56 | 57 | COPY entrypoint.sh healthcheck.sh / 58 | RUN chmod +x /entrypoint.sh /healthcheck.sh 59 | 60 | VOLUME /data 61 | EXPOSE ${FLATNOTES_PORT}/tcp 62 | HEALTHCHECK --interval=60s --timeout=10s CMD /healthcheck.sh 63 | 64 | ENTRYPOINT [ "/entrypoint.sh" ] 65 | -------------------------------------------------------------------------------- /Dockerfile.experimental: -------------------------------------------------------------------------------- 1 | ARG BUILD_DIR=/build 2 | 3 | # Client Build Container 4 | FROM --platform=$BUILDPLATFORM node:20-alpine AS build 5 | 6 | ARG BUILD_DIR 7 | 8 | RUN mkdir ${BUILD_DIR} 9 | WORKDIR ${BUILD_DIR} 10 | 11 | COPY .htmlnanorc \ 12 | package.json \ 13 | package-lock.json \ 14 | postcss.config.js \ 15 | tailwind.config.js \ 16 | vite.config.js \ 17 | ./ 18 | 19 | RUN npm ci 20 | 21 | COPY client ./client 22 | RUN npm run build 23 | 24 | # Pipenv Build Container 25 | FROM python:3.11-alpine3.20 as pipenv-build 26 | 27 | ARG BUILD_DIR 28 | 29 | ENV APP_PATH=/app 30 | 31 | RUN apk add --no-cache build-base rust cargo libffi libffi-dev libssl3 openssl-dev 32 | 33 | RUN pip install --no-cache-dir pipenv 34 | 35 | WORKDIR ${APP_PATH} 36 | 37 | COPY LICENSE Pipfile Pipfile.lock ./ 38 | RUN mkdir .venv 39 | RUN pipenv install --deploy --ignore-pipfile && \ 40 | pipenv --clear 41 | 42 | # Runtime Container 43 | FROM python:3.11-alpine3.20 44 | 45 | ARG BUILD_DIR 46 | 47 | ENV PUID=1000 48 | ENV PGID=1000 49 | ENV EXEC_TOOL=su-exec 50 | ENV FLATNOTES_HOST=0.0.0.0 51 | ENV FLATNOTES_PORT=8080 52 | 53 | ENV APP_PATH=/app 54 | ENV FLATNOTES_PATH=/data 55 | 56 | RUN mkdir -p ${APP_PATH} 57 | RUN mkdir -p ${FLATNOTES_PATH} 58 | 59 | RUN apk add --no-cache su-exec libssl3 libgcc curl 60 | 61 | WORKDIR ${APP_PATH} 62 | 63 | COPY server ./server 64 | COPY --from=build --chmod=777 ${BUILD_DIR}/client/dist ./client/dist 65 | COPY --from=pipenv-build ${APP_PATH}/.venv/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/ 66 | 67 | COPY entrypoint.sh healthcheck.sh / 68 | RUN chmod +x /entrypoint.sh /healthcheck.sh 69 | 70 | VOLUME /data 71 | EXPOSE ${FLATNOTES_PORT}/tcp 72 | HEALTHCHECK --interval=60s --timeout=10s CMD /healthcheck.sh 73 | 74 | ENTRYPOINT [ "/entrypoint.sh" ] 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Adam Dullage 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | black = "*" 8 | flake8 = "*" 9 | rope = "*" 10 | isort = "*" 11 | 12 | [packages] 13 | whoosh = "==2.7.4" 14 | fastapi = "==0.115.12" 15 | uvicorn = {extras = ["standard"], version = "==0.34.0"} 16 | aiofiles = "==24.1.0" 17 | python-jose = {extras = ["cryptography"], version = "==3.4.0"} 18 | pyotp = "==2.9.0" 19 | qrcode = "==8.0" 20 | python-multipart = "==0.0.20" 21 | 22 | [requires] 23 | python_version = "3.11" 24 | 25 | [pipenv] 26 | allow_prereleases = true 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

5 | Docker Pulls 6 |

7 | 8 | A self-hosted, database-less note-taking web app that utilises a flat folder of markdown files for storage. 9 | 10 | Log into the [demo site](https://demo.flatnotes.io) and take a look around. *Note: This site resets every 15 minutes.* 11 | 12 | ## Contents 13 | 14 | * [Design Principle](#design-principle) 15 | * [Features](#features) 16 | * [Getting Started](#getting-started) 17 | * [Hosted](#hosted) 18 | * [Self Hosted](#self-hosted) 19 | * [Roadmap](#roadmap) 20 | * [Contributing](#contributing) 21 | * [Sponsorship](#sponsorship) 22 | * [Thanks](#thanks) 23 | 24 | ## Design Principle 25 | 26 | flatnotes is designed to be a distraction-free note-taking app that puts your note content first. This means: 27 | 28 | * A clean and simple user interface. 29 | * No folders, notebooks or anything like that. Just all of your notes, backed by powerful search and tagging functionality. 30 | * Quick access to a full-text search from anywhere in the app (keyboard shortcut "/"). 31 | 32 | Another key design principle is not to take your notes hostage. Your notes are just markdown files. There's no database, proprietary formatting, complicated folder structures or anything like that. You're free at any point to just move the files elsewhere and use another app. 33 | 34 | Equally, the only thing flatnotes caches is the search index and that's incrementally synced on every search (and when flatnotes first starts). This means that you're free to add, edit & delete the markdown files outside of flatnotes even whilst flatnotes is running. 35 | 36 | ## Features 37 | 38 | * Mobile responsive web interface. 39 | * Raw/WYSIWYG markdown editor modes. 40 | * Advanced search functionality. 41 | * Note "tagging" functionality. 42 | * Customisable home page. 43 | * Wikilink support to easily link to other notes (`[[My Other Note]]`). 44 | * Light/dark themes. 45 | * Multiple authentication options (none, read-only, username/password, 2FA). 46 | * Restful API. 47 | 48 | See [the wiki](https://github.com/dullage/flatnotes/wiki) for more details. 49 | 50 | ## Getting Started 51 | 52 | ### Hosted 53 | 54 | A quick and easy way to get started with flatnotes is to host it on PikaPods. Just click the button below and follow the instructions. 55 | 56 | [![PikaPods](https://www.pikapods.com/static/run-button-34.svg)](https://www.pikapods.com/pods?run=flatnotes) 57 | 58 | 59 | ### Self Hosted 60 | 61 | If you'd prefer to host flatnotes yourself then the recommendation is to use Docker. 62 | 63 | ### Example Docker Run Command 64 | 65 | ```shell 66 | docker run -d \ 67 | -e "PUID=1000" \ 68 | -e "PGID=1000" \ 69 | -e "FLATNOTES_AUTH_TYPE=password" \ 70 | -e "FLATNOTES_USERNAME=user" \ 71 | -e 'FLATNOTES_PASSWORD=changeMe!' \ 72 | -e "FLATNOTES_SECRET_KEY=aLongRandomSeriesOfCharacters" \ 73 | -v "$(pwd)/data:/data" \ 74 | -p "8080:8080" \ 75 | dullage/flatnotes:latest 76 | ``` 77 | 78 | ### Example Docker Compose 79 | ```yaml 80 | version: "3" 81 | 82 | services: 83 | flatnotes: 84 | container_name: flatnotes 85 | image: dullage/flatnotes:latest 86 | environment: 87 | PUID: 1000 88 | PGID: 1000 89 | FLATNOTES_AUTH_TYPE: "password" 90 | FLATNOTES_USERNAME: "user" 91 | FLATNOTES_PASSWORD: "changeMe!" 92 | FLATNOTES_SECRET_KEY: "aLongRandomSeriesOfCharacters" 93 | volumes: 94 | - "./data:/data" 95 | # Optional. Allows you to save the search index in a different location: 96 | # - "./index:/data/.flatnotes" 97 | ports: 98 | - "8080:8080" 99 | restart: unless-stopped 100 | ``` 101 | 102 | See the [Environment Variables](https://github.com/dullage/flatnotes/wiki/Environment-Variables) article in the wiki for a full list of configuration options. 103 | 104 | ## Roadmap 105 | 106 | I want to keep flatnotes as simple and distraction-free as possible which means limiting new features. This said, I welcome feedback and suggestions. 107 | 108 | ## Contributing 109 | 110 | If you're interested in contributing to flatnotes, then please read the [CONTRIBUTING.md](CONTRIBUTING.md) file. 111 | 112 | ## Sponsorship 113 | 114 | If you find this project useful, please consider buying me a beer. It would genuinely make my day. 115 | 116 | [![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/Dullage) 117 | 118 | ## Thanks 119 | 120 | A special thanks to 2 fantastic open-source projects that make flatnotes possible. 121 | 122 | * [Whoosh](https://whoosh.readthedocs.io/en/latest/intro.html) - A fast, pure Python search engine library. 123 | * [TOAST UI Editor](https://ui.toast.com/tui-editor) - A GFM Markdown and WYSIWYG editor for the browser. 124 | -------------------------------------------------------------------------------- /client/App.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 93 | -------------------------------------------------------------------------------- /client/api.js: -------------------------------------------------------------------------------- 1 | import * as constants from "./constants.js"; 2 | 3 | import { Note, SearchResult } from "./classes.js"; 4 | 5 | import axios from "axios"; 6 | import { getStoredToken } from "./tokenStorage.js"; 7 | import { getToastOptions } from "./helpers.js"; 8 | import router from "./router.js"; 9 | 10 | const api = axios.create(); 11 | 12 | api.interceptors.request.use( 13 | // If the request is not for the token endpoint, add the token to the headers. 14 | function (config) { 15 | if (config.url !== "api/token") { 16 | const token = getStoredToken(); 17 | if (token) { 18 | config.headers.Authorization = `Bearer ${token}`; 19 | } 20 | } 21 | return config; 22 | }, 23 | function (error) { 24 | return Promise.reject(error); 25 | }, 26 | ); 27 | 28 | export function apiErrorHandler(error, toast) { 29 | if (error.response?.status === 401) { 30 | const redirectPath = router.currentRoute.value.fullPath; 31 | router.push({ 32 | name: "login", 33 | query: { [constants.params.redirect]: redirectPath }, 34 | }); 35 | } else { 36 | console.error(error); 37 | toast.add( 38 | getToastOptions( 39 | "Unknown error communicating with the server. Please try again.", 40 | "Unknown Error", 41 | "error", 42 | ), 43 | ); 44 | } 45 | } 46 | 47 | export async function getConfig() { 48 | try { 49 | const response = await api.get("api/config"); 50 | return response.data; 51 | } catch (error) { 52 | return Promise.reject(error); 53 | } 54 | } 55 | 56 | export async function getToken(username, password, totp) { 57 | try { 58 | const response = await api.post("api/token", { 59 | username: username, 60 | password: totp ? password + totp : password, 61 | }); 62 | return response.data.access_token; 63 | } catch (response) { 64 | return Promise.reject(response); 65 | } 66 | } 67 | 68 | export async function getNotes(term, sort, order, limit) { 69 | try { 70 | const response = await api.get("api/search", { 71 | params: { 72 | term: term, 73 | sort: sort, 74 | order: order, 75 | limit: limit, 76 | }, 77 | }); 78 | return response.data.map((note) => new SearchResult(note)); 79 | } catch (response) { 80 | return Promise.reject(response); 81 | } 82 | } 83 | 84 | export async function createNote(title, content) { 85 | try { 86 | const response = await api.post("api/notes", { 87 | title: title, 88 | content: content, 89 | }); 90 | return new Note(response.data); 91 | } catch (response) { 92 | return Promise.reject(response); 93 | } 94 | } 95 | 96 | export async function getNote(title) { 97 | try { 98 | const response = await api.get(`api/notes/${encodeURIComponent(title)}`); 99 | return new Note(response.data); 100 | } catch (response) { 101 | return Promise.reject(response); 102 | } 103 | } 104 | 105 | export async function updateNote(title, newTitle, newContent) { 106 | try { 107 | const response = await api.patch(`api/notes/${encodeURIComponent(title)}`, { 108 | newTitle: newTitle, 109 | newContent: newContent, 110 | }); 111 | return new Note(response.data); 112 | } catch (response) { 113 | return Promise.reject(response); 114 | } 115 | } 116 | 117 | export async function deleteNote(title) { 118 | try { 119 | await api.delete(`api/notes/${encodeURIComponent(title)}`); 120 | } catch (response) { 121 | return Promise.reject(response); 122 | } 123 | } 124 | 125 | export async function getTags() { 126 | try { 127 | const response = await api.get("api/tags"); 128 | return response.data; 129 | } catch (response) { 130 | return Promise.reject(response); 131 | } 132 | } 133 | 134 | export async function createAttachment(file) { 135 | try { 136 | const formData = new FormData(); 137 | formData.append("file", file); 138 | const response = await api.post("api/attachments", formData, { 139 | headers: { 140 | "Content-Type": "multipart/form-data", 141 | }, 142 | }); 143 | return response.data; 144 | } catch (response) { 145 | return Promise.reject(response); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /client/assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dullage/flatnotes/cf355dd8586cc484cde23e6a35c8fcf5dca331ac/client/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /client/assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dullage/flatnotes/cf355dd8586cc484cde23e6a35c8fcf5dca331ac/client/assets/favicon-16x16.png -------------------------------------------------------------------------------- /client/assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dullage/flatnotes/cf355dd8586cc484cde23e6a35c8fcf5dca331ac/client/assets/favicon-32x32.png -------------------------------------------------------------------------------- /client/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dullage/flatnotes/cf355dd8586cc484cde23e6a35c8fcf5dca331ac/client/assets/favicon.ico -------------------------------------------------------------------------------- /client/assets/fonts/Poppins/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | https://openfontlicense.org 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /client/assets/fonts/Poppins/Poppins-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dullage/flatnotes/cf355dd8586cc484cde23e6a35c8fcf5dca331ac/client/assets/fonts/Poppins/Poppins-Italic.ttf -------------------------------------------------------------------------------- /client/assets/fonts/Poppins/Poppins-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dullage/flatnotes/cf355dd8586cc484cde23e6a35c8fcf5dca331ac/client/assets/fonts/Poppins/Poppins-Regular.ttf -------------------------------------------------------------------------------- /client/assets/fonts/Poppins/Poppins-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dullage/flatnotes/cf355dd8586cc484cde23e6a35c8fcf5dca331ac/client/assets/fonts/Poppins/Poppins-SemiBold.ttf -------------------------------------------------------------------------------- /client/assets/fonts/Poppins/Poppins-SemiBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dullage/flatnotes/cf355dd8586cc484cde23e6a35c8fcf5dca331ac/client/assets/fonts/Poppins/Poppins-SemiBoldItalic.ttf -------------------------------------------------------------------------------- /client/classes.js: -------------------------------------------------------------------------------- 1 | import router from "./router.js"; 2 | 3 | class Note { 4 | constructor(note) { 5 | this.title = note?.title; 6 | this.lastModified = note?.lastModified; 7 | this.content = note?.content; 8 | } 9 | 10 | get lastModifiedAsDate() { 11 | return new Date(this.lastModified * 1000); 12 | } 13 | 14 | get lastModifiedAsString() { 15 | return this.lastModifiedAsDate.toLocaleString(); 16 | } 17 | } 18 | 19 | class SearchResult extends Note { 20 | constructor(searchResult) { 21 | super(searchResult); 22 | this.score = searchResult.score; 23 | this.titleHighlights = searchResult.titleHighlights; 24 | this.contentHighlights = searchResult.contentHighlights; 25 | this.tagMatches = searchResult.tagMatches; 26 | } 27 | 28 | get titleHighlightsOrTitle() { 29 | return this.titleHighlights ? this.titleHighlights : this.title; 30 | } 31 | 32 | get includesHighlights() { 33 | if ( 34 | this.titleHighlights || 35 | this.contentHighlights || 36 | (this.tagMatches != null && this.tagMatches.length) 37 | ) { 38 | return true; 39 | } else { 40 | return false; 41 | } 42 | } 43 | } 44 | 45 | export { Note, SearchResult }; 46 | -------------------------------------------------------------------------------- /client/components/ConfirmModal.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 59 | -------------------------------------------------------------------------------- /client/components/CustomButton.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 36 | -------------------------------------------------------------------------------- /client/components/IconLabel.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 26 | -------------------------------------------------------------------------------- /client/components/LoadingIndicator.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 71 | 72 | 133 | -------------------------------------------------------------------------------- /client/components/Logo.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 42 | -------------------------------------------------------------------------------- /client/components/Modal.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 44 | -------------------------------------------------------------------------------- /client/components/PrimeMenu.vue: -------------------------------------------------------------------------------- 1 | 15 | 43 | -------------------------------------------------------------------------------- /client/components/PrimeToast.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | -------------------------------------------------------------------------------- /client/components/Tag.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /client/components/TextInput.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 16 | -------------------------------------------------------------------------------- /client/components/Toggle.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /client/components/toastui/ToastEditor.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 55 | 56 | 62 | -------------------------------------------------------------------------------- /client/components/toastui/ToastViewer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 27 | 28 | 34 | -------------------------------------------------------------------------------- /client/components/toastui/baseOptions.js: -------------------------------------------------------------------------------- 1 | import codeSyntaxHighlight from "@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight-all.js"; 2 | import router from "../../router.js"; 3 | 4 | const customHTMLRenderer = { 5 | // Add id attribute to headings 6 | heading(node, { entering, getChildrenText, origin }) { 7 | const original = origin(); 8 | if (entering) { 9 | original.attributes = { 10 | id: getChildrenText(node) 11 | .toLowerCase() 12 | .replace(/[^a-z0-9-\s]*/g, "") 13 | .trim() 14 | .replace(/\s/g, "-"), 15 | }; 16 | } 17 | return original; 18 | }, 19 | // Convert relative hash links to absolute links 20 | link(_, { entering, origin }) { 21 | const original = origin(); 22 | if (entering) { 23 | const href = original.attributes.href; 24 | if (href.startsWith("#")) { 25 | const targetRoute = { 26 | ...router.currentRoute.value, 27 | hash: href, 28 | }; 29 | original.attributes.href = router.resolve(targetRoute).href; 30 | } 31 | } 32 | return original; 33 | }, 34 | }; 35 | 36 | const baseOptions = { 37 | height: "100%", 38 | plugins: [codeSyntaxHighlight], 39 | customHTMLRenderer: customHTMLRenderer, 40 | usageStatistics: false, 41 | }; 42 | 43 | export default baseOptions; 44 | -------------------------------------------------------------------------------- /client/components/toastui/extendedAutolinks.js: -------------------------------------------------------------------------------- 1 | import { params, searchSortOptions } from "../../constants.js"; 2 | 3 | import router from "../../router.js"; 4 | 5 | /* 6 | * Sourced from toast-ui. Their autolink options are 7 | * either override their built in functionality or 8 | * use their built in functionality. We'd like to have 9 | * both so this is the source of their parsers. 10 | */ 11 | const DOMAIN = "(?:[w-]+.)*[A-Za-z0-9-]+.[A-Za-z0-9-]+"; 12 | const PATH = "[^<\\s]*[^ { 82 | const text = match[1]; 83 | return { 84 | text, 85 | range: [match.index, match.index + match[0].length - 1], 86 | url: `${router.resolve({ name: "note", params: { title: text.trim() } }).href}`, 87 | }; 88 | }); 89 | } 90 | 91 | return null; 92 | } 93 | 94 | function parseTagLink(source) { 95 | const matched = source.matchAll(/(?:^|\s)(#[a-zA-Z0-9_-]+)(?=\s|$)/g); 96 | if (matched) { 97 | return Array.from(matched).map((match) => { 98 | const text = match[1]; 99 | return { 100 | text, 101 | range: [ 102 | match.index + match[0].indexOf(text), 103 | match.index + match[0].indexOf(text) + text.length - 1, 104 | ], 105 | url: `${ 106 | router.resolve({ 107 | name: "search", 108 | query: { 109 | [params.searchTerm]: text, 110 | [params.sortBy]: searchSortOptions.title, 111 | }, 112 | }).href 113 | }`, 114 | }; 115 | }); 116 | } 117 | 118 | return null; 119 | } 120 | 121 | function extendedAutolinks(source) { 122 | return [ 123 | ...parseUrlLink(source), 124 | ...parseEmailLink(source), 125 | ...parseWikiLink(source), 126 | ...parseTagLink(source), 127 | ].sort((a, b) => a.range[0] - b.range[0]); 128 | } 129 | 130 | export default extendedAutolinks; 131 | -------------------------------------------------------------------------------- /client/components/toastui/toastui-editor-overrides.scss: -------------------------------------------------------------------------------- 1 | .ProseMirror, 2 | .toastui-editor-defaultUI .ProseMirror, 3 | .toastui-editor-md-container .toastui-editor-md-preview { 4 | padding: 1rem 0 0 0; 5 | } 6 | 7 | .ProseMirror { 8 | height: 100%; 9 | } 10 | 11 | .toastui-editor-contents ul > li::before { 12 | // Vertically center the bullet point 13 | margin-top: 0.7rem; 14 | } 15 | 16 | // Typography 17 | .ProseMirror, 18 | .toastui-editor-contents { 19 | font-family: "Poppins", sans-serif; 20 | font-size: 1rem; 21 | } 22 | 23 | .toastui-editor-contents, 24 | .ProseMirror { 25 | h1, 26 | .toastui-editor-md-heading1, 27 | h2, 28 | .toastui-editor-md-heading2, 29 | h3, 30 | .toastui-editor-md-heading3, 31 | h4, 32 | .toastui-editor-md-heading4, 33 | h5, 34 | .toastui-editor-md-heading5, 35 | h6, 36 | .toastui-editor-md-heading6 { 37 | font-weight: bold; 38 | line-height: 1.4; 39 | margin: 1em 0 0.5em 0; 40 | padding: 0; 41 | border-bottom: none; 42 | 43 | &:first-of-type { 44 | margin-top: 0; 45 | } 46 | } 47 | 48 | h1, 49 | .toastui-editor-md-heading1 { 50 | font-size: 1.75rem; 51 | } 52 | 53 | h2, 54 | .toastui-editor-md-heading2 { 55 | font-size: 1.6rem; 56 | } 57 | 58 | h3, 59 | .toastui-editor-md-heading3 { 60 | font-size: 1.45rem; 61 | } 62 | 63 | h4, 64 | .toastui-editor-md-heading4 { 65 | font-size: 1.3rem; 66 | } 67 | 68 | h5, 69 | .toastui-editor-md-heading5 { 70 | font-size: 1.15rem; 71 | } 72 | 73 | h6, 74 | .toastui-editor-md-heading6 { 75 | font-size: 1rem; 76 | } 77 | 78 | p { 79 | line-height: 1.6rem; 80 | margin: 0 0 1rem 0; 81 | } 82 | } 83 | 84 | // Override the default font-family for code blocks as some of the fallbacks are not monospace 85 | .toastui-editor-contents code, 86 | .toastui-editor-contents pre, 87 | .toastui-editor-md-code, 88 | .toastui-editor-md-code-block { 89 | font-family: Consolas, "Lucida Console", Monaco, "Andale Mono", monospace; 90 | } 91 | 92 | // Colours 93 | .toastui-editor-defaultUI { 94 | border: none; 95 | } 96 | 97 | .toastui-editor-contents { 98 | h1, 99 | h2, 100 | h3, 101 | h4, 102 | h5, 103 | h6 { 104 | color: rgb(var(--theme-text)); 105 | } 106 | p { 107 | color: rgb(var(--theme-text)); 108 | } 109 | pre code { 110 | color: rgb(var(--theme-text)); 111 | } 112 | } 113 | 114 | .toastui-editor-main { 115 | background-color: rgb(var(--theme-background)); 116 | } 117 | 118 | .toastui-editor-ww-container { 119 | background-color: rgb(var(--theme-background)); 120 | } 121 | 122 | .toastui-editor-contents ul, 123 | .toastui-editor-contents menu, 124 | .toastui-editor-contents ol, 125 | .toastui-editor-contents dir { 126 | color: rgb(var(--theme-text)); 127 | } 128 | 129 | // Code Block 130 | .toastui-editor-contents pre, 131 | .toastui-editor-md-code-block-line-background { 132 | background-color: rgb(var(--theme-background-elevated)); 133 | } 134 | 135 | .dark .toastui-editor-contents pre, 136 | .dark .toastui-editor-md-code-block-line-background { 137 | background-color: rgb(var(--theme-background-elevated)); 138 | } 139 | 140 | .token.operator, 141 | .token.entity, 142 | .token.url, 143 | .language-css .token.string, 144 | .style .token.string { 145 | background: none; 146 | } 147 | 148 | // Tables 149 | .toastui-editor-contents table th { 150 | color: rgb(var(--theme-text)); 151 | background-color: rgb(var(--theme-background-elevated)); 152 | } 153 | 154 | .toastui-editor-contents table { 155 | color: rgb(var(--theme-text)); 156 | } 157 | 158 | .toastui-editor-md-table .toastui-editor-md-table-cell { 159 | color: rgb(var(--theme-text)); 160 | } 161 | 162 | // Editor 163 | .ProseMirror { 164 | color: rgb(var(--theme-text)); 165 | } 166 | 167 | // Toolbar 168 | .toastui-editor-defaultUI-toolbar { 169 | background-color: rgb(var(--theme-background)); 170 | border-bottom-color: rgb(var(--theme-border)); 171 | } 172 | 173 | .toastui-editor-defaultUI .toastui-editor-md-tab-container { 174 | background-color: rgb(var(--theme-background)); 175 | border-bottom-color: rgb(var(--theme-border)); 176 | } 177 | 178 | .toastui-editor-mode-switch { 179 | background-color: rgb(var(--theme-background)); 180 | border-color: rgb(var(--theme-border)); 181 | } 182 | 183 | .toastui-editor-defaultUI-toolbar button { 184 | border: 1px solid rgb(var(--theme-background)); 185 | } 186 | 187 | .toastui-editor-defaultUI .tab-item.active { 188 | background-color: rgb(var(--theme-background)); 189 | color: rgb(var(--theme-text)); 190 | border-color: rgb(var(--theme-border)); 191 | } 192 | 193 | .toastui-editor-defaultUI .tab-item { 194 | background-color: rgb(var(--theme-background-elevated)); 195 | color: rgb(var(--theme-text-muted)); 196 | border-color: rgb(var(--theme-border)); 197 | } 198 | 199 | .toastui-editor-md-tab-container .tab-item.active { 200 | border-bottom: none; 201 | } 202 | 203 | .toastui-editor-toolbar-divider { 204 | background-color: rgb(var(--theme-text)); 205 | } 206 | 207 | .dark .toastui-editor-toolbar-icons { 208 | // Standard dark theme buttons are dark grey, this position change makes them white 209 | background-position-y: -49px; 210 | } 211 | 212 | .toastui-editor-defaultUI-toolbar button:not(:disabled):hover { 213 | background-color: rgb(var(--theme-background-elevated)); 214 | border: 1px solid rgb(var(--theme-background)); 215 | } 216 | 217 | .toastui-editor-md-block-quote .toastui-editor-md-marked-text { 218 | color: rgb(var(--theme-text-muted)); 219 | } 220 | 221 | .toastui-editor-md-code, 222 | .toastui-editor-contents code { 223 | background-color: rgb(var(--theme-background-elevated)); 224 | } 225 | 226 | .dark .toastui-editor-md-code, 227 | .dark .toastui-editor-contents code { 228 | background-color: rgb(var(--theme-background-elevated)); 229 | } 230 | 231 | .toastui-editor-popup { 232 | background-color: rgb(var(--theme-background)); 233 | border: 1px solid rgb(var(--theme-border)); 234 | } 235 | 236 | .toastui-editor-popup-body { 237 | label { 238 | color: rgb(var(--theme-text)); 239 | } 240 | input { 241 | background-color: rgb(var(--theme-background)); 242 | border: 1px solid rgb(var(--theme-border)); 243 | } 244 | } 245 | 246 | .toastui-editor-popup-add-table .toastui-editor-table-cell, 247 | .toastui-editor-popup-add-table .toastui-editor-table-cell.header { 248 | background-color: rgb(var(--theme-background)); 249 | border-color: rgb(var(--theme-border)); 250 | } 251 | 252 | .toastui-editor-popup-add-heading ul li:hover { 253 | background-color: rgb(var(--theme-background-elevated)); 254 | } 255 | 256 | .toastui-editor-popup-add-image .toastui-editor-tabs .tab-item { 257 | color: rgb(var(--theme-text)); 258 | background-color: rgb(var(--theme-background)); 259 | border-color: rgb(var(--theme-border)); 260 | } 261 | 262 | .toastui-editor-popup-add-image .toastui-editor-file-name.has-file { 263 | color: rgb(var(--theme-text)); 264 | } 265 | 266 | .toastui-editor-popup-body input[type="text"] { 267 | color: rgb(var(--theme-text)); 268 | background-color: rgb(var(--theme-background)); 269 | } 270 | 271 | .toastui-editor-dropdown-toolbar { 272 | background-color: rgb(var(--theme-background)); 273 | border-color: rgb(var(--theme-border)); 274 | } 275 | 276 | // Tables 277 | .toastui-editor-contents table th, 278 | .toastui-editor-contents table td { 279 | border-color: rgb(var(--theme-border)); 280 | } 281 | 282 | .toastui-editor-contents th p { 283 | color: rgb(var(--theme-text)); 284 | } 285 | -------------------------------------------------------------------------------- /client/constants.js: -------------------------------------------------------------------------------- 1 | // Params 2 | export const params = { 3 | searchTerm: "term", 4 | redirect: "redirect", 5 | showHighlights: "showHighlights", 6 | sortBy: "sortBy", 7 | }; 8 | 9 | export const searchSortOptions = { 10 | score: 0, 11 | title: 1, 12 | lastModified: 2, 13 | }; 14 | 15 | export const authTypes = { 16 | none: "none", 17 | readOnly: "read_only", 18 | password: "password", 19 | totp: "totp", 20 | }; 21 | -------------------------------------------------------------------------------- /client/globalStore.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { ref } from "vue"; 3 | 4 | export const useGlobalStore = defineStore("global", () => { 5 | const config = ref({}); 6 | 7 | return { config }; 8 | }); 9 | -------------------------------------------------------------------------------- /client/helpers.js: -------------------------------------------------------------------------------- 1 | export function getToastOptions(description, title, severity) { 2 | return { 3 | summary: title, 4 | detail: description, 5 | severity: severity, 6 | closable: false, 7 | life: 5000, 8 | }; 9 | } 10 | 11 | export function setDarkThemeOn(save = true) { 12 | document.body.classList.add("dark"); 13 | if (save) localStorage.setItem("darkTheme", "true"); 14 | } 15 | 16 | export function setDarkThemeOff(save = true) { 17 | document.body.classList.remove("dark"); 18 | if (save) localStorage.setItem("darkTheme", "false"); 19 | } 20 | 21 | export function toggleTheme() { 22 | document.body.classList.contains("dark") 23 | ? setDarkThemeOff() 24 | : setDarkThemeOn(); 25 | } 26 | 27 | export function loadTheme() { 28 | const storedTheme = localStorage.getItem("darkTheme"); 29 | if (storedTheme === "true") { 30 | setDarkThemeOn(); 31 | } else if ( 32 | storedTheme === null && 33 | window.matchMedia("(prefers-color-scheme: dark)").matches 34 | ) { 35 | setDarkThemeOn(false); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 17 | 23 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | flatnotes 36 | 37 | 38 |
39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | import App from "/App.vue"; 2 | import PrimeVue from "primevue/config"; 3 | import ToastService from "primevue/toastservice"; 4 | import { createApp } from "vue"; 5 | import { createPinia } from "pinia"; 6 | import router from "/router.js"; 7 | 8 | const app = createApp(App); 9 | const pinia = createPinia(); 10 | 11 | app.use(router); 12 | app.use(pinia); 13 | app.use(PrimeVue, { unstyled: true }); 14 | app.use(ToastService); 15 | 16 | // Custom v-focus directive to focus on an element when mounted 17 | app.directive("focus", { 18 | mounted(el) { 19 | el.focus(); 20 | }, 21 | }); 22 | 23 | app.mount("#app"); 24 | -------------------------------------------------------------------------------- /client/partials/NavBar.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 106 | -------------------------------------------------------------------------------- /client/partials/SearchInput.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 204 | -------------------------------------------------------------------------------- /client/partials/SearchModal.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 22 | -------------------------------------------------------------------------------- /client/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dullage/flatnotes/cf355dd8586cc484cde23e6a35c8fcf5dca331ac/client/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /client/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dullage/flatnotes/cf355dd8586cc484cde23e6a35c8fcf5dca331ac/client/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /client/public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /client/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flatnotes", 3 | "short_name": "flatnotes", 4 | "start_url": "/", 5 | "icons": [ 6 | { 7 | "src": "android-chrome-192x192.png", 8 | "sizes": "192x192", 9 | "type": "image/png", 10 | "purpose": "any maskable" 11 | }, 12 | { 13 | "src": "android-chrome-512x512.png", 14 | "sizes": "512x512", 15 | "type": "image/png", 16 | "purpose": "any maskable" 17 | } 18 | ], 19 | "theme_color": "#F8A66B", 20 | "background_color": "#ffffff", 21 | "display": "standalone" 22 | } 23 | -------------------------------------------------------------------------------- /client/router.js: -------------------------------------------------------------------------------- 1 | import * as constants from "./constants.js"; 2 | 3 | import { createRouter, createWebHistory } from "vue-router"; 4 | 5 | const router = createRouter({ 6 | history: createWebHistory(""), 7 | routes: [ 8 | { 9 | path: "/", 10 | name: "home", 11 | component: () => import("./views/Home.vue"), 12 | }, 13 | { 14 | path: "/login", 15 | name: "login", 16 | component: () => import("./views/LogIn.vue"), 17 | props: (route) => ({ redirect: route.query[constants.params.redirect] }), 18 | }, 19 | { 20 | path: "/note/:title", 21 | name: "note", 22 | component: () => import("./views/Note.vue"), 23 | props: true, 24 | }, 25 | { 26 | path: "/new", 27 | name: "new", 28 | component: () => import("./views/Note.vue"), 29 | }, 30 | { 31 | path: "/search", 32 | name: "search", 33 | component: () => import("./views/SearchResults.vue"), 34 | props: (route) => ({ 35 | searchTerm: route.query[constants.params.searchTerm], 36 | sortBy: Number(route.query[constants.params.sortBy]) || undefined, 37 | }), 38 | }, 39 | ], 40 | }); 41 | 42 | router.afterEach((to) => { 43 | let title = "flatnotes"; 44 | if (to.name === "note") { 45 | if (to.params.title) { 46 | title = `${to.params.title} - ${title}`; 47 | } else { 48 | title = "New Note - " + title; 49 | } 50 | } 51 | document.title = title; 52 | }); 53 | 54 | export default router; 55 | -------------------------------------------------------------------------------- /client/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | @font-face { 7 | font-family: "Poppins"; 8 | font-style: normal; 9 | font-weight: 400; 10 | font-display: swap; 11 | src: url("assets/fonts/Poppins/Poppins-Regular.ttf"); 12 | } 13 | 14 | @font-face { 15 | font-family: "Poppins"; 16 | font-style: italic; 17 | font-weight: 400; 18 | font-display: swap; 19 | src: url("assets/fonts/Poppins/Poppins-Italic.ttf"); 20 | } 21 | 22 | @font-face { 23 | font-family: "Poppins"; 24 | font-style: normal; 25 | font-weight: 600; 26 | font-display: swap; 27 | src: url("assets/fonts/Poppins/Poppins-SemiBold.ttf"); 28 | } 29 | 30 | @font-face { 31 | font-family: "Poppins"; 32 | font-style: italic; 33 | font-weight: 600; 34 | font-display: swap; 35 | src: url("assets/fonts/Poppins/Poppins-SemiBoldItalic.ttf"); 36 | } 37 | 38 | body { 39 | --theme-brand: 248 166 107; 40 | --theme-background: 255 255 255; 41 | --theme-background-elevated: 243 244 245; 42 | --theme-text: 44 49 57; 43 | --theme-text-muted: 136 145 161; 44 | --theme-text-very-muted: 193 199 208; 45 | --theme-shadow: 236 238 240; 46 | --theme-border: 236 238 240; 47 | } 48 | 49 | body.dark { 50 | /* --theme-brand: 248 166 107; */ 51 | --theme-background: 34 38 44; 52 | --theme-background-elevated: 44 49 57; 53 | --theme-text: 193 199 208; 54 | --theme-text-muted: 136 145 161; 55 | --theme-text-very-muted: 94 107 128; 56 | --theme-shadow: none; 57 | --theme-border: 94 107 128; 58 | } 59 | } -------------------------------------------------------------------------------- /client/tokenStorage.js: -------------------------------------------------------------------------------- 1 | const tokenStorageKey = "token"; 2 | 3 | function getCookieString(token) { 4 | return `${tokenStorageKey}=${token}; SameSite=Strict`; 5 | } 6 | 7 | export function storeToken(token, persist = false) { 8 | document.cookie = getCookieString(token); 9 | sessionStorage.setItem(tokenStorageKey, token); 10 | if (persist === true) { 11 | localStorage.setItem(tokenStorageKey, token); 12 | } 13 | } 14 | 15 | export function getStoredToken() { 16 | return sessionStorage.getItem(tokenStorageKey); 17 | } 18 | 19 | export function loadStoredToken() { 20 | const token = localStorage.getItem(tokenStorageKey); 21 | if (token != null) { 22 | storeToken(token, false); 23 | } 24 | } 25 | 26 | export function clearStoredToken() { 27 | sessionStorage.removeItem(tokenStorageKey); 28 | localStorage.removeItem(tokenStorageKey); 29 | document.cookie = 30 | getCookieString() + "; expires=Thu, 01 Jan 1970 00:00:00 GMT"; 31 | } 32 | -------------------------------------------------------------------------------- /client/views/Home.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 88 | -------------------------------------------------------------------------------- /client/views/LogIn.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 105 | -------------------------------------------------------------------------------- /client/views/Note.vue: -------------------------------------------------------------------------------- 1 | 110 | 111 | 120 | 121 | 530 | -------------------------------------------------------------------------------- /client/views/SearchResults.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 154 | 155 | 160 | -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ "$EXEC_TOOL" ] || EXEC_TOOL=gosu 4 | [ "$FLATNOTES_HOST" ] || FLATNOTES_HOST=0.0.0.0 5 | [ "$FLATNOTES_PORT" ] || FLATNOTES_PORT=8080 6 | 7 | set -e 8 | 9 | echo "\ 10 | ====================================== 11 | ======== Welcome to flatnotes ======== 12 | ====================================== 13 | 14 | If you enjoy using flatnotes, please 15 | consider sponsoring the project at: 16 | 17 | https://sponsor.flatnotes.io 18 | 19 | It would really make my day 🙏. 20 | 21 | ────────────────────────────────────── 22 | " 23 | 24 | flatnotes_command="python -m \ 25 | uvicorn \ 26 | main:app \ 27 | --app-dir server \ 28 | --host ${FLATNOTES_HOST} \ 29 | --port ${FLATNOTES_PORT} \ 30 | --proxy-headers \ 31 | --forwarded-allow-ips '*'" 32 | 33 | if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then 34 | echo Setting file permissions... 35 | chown -R ${PUID}:${PGID} ${FLATNOTES_PATH} 36 | 37 | echo Starting flatnotes as user ${PUID}... 38 | exec ${EXEC_TOOL} ${PUID}:${PGID} ${flatnotes_command} 39 | 40 | else 41 | echo "A user was set by docker, skipping file permission changes." 42 | echo Starting flatnotes as user $(id -u)... 43 | exec ${flatnotes_command} 44 | fi 45 | -------------------------------------------------------------------------------- /healthcheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | curl -f http://localhost:${FLATNOTES_PORT}${FLATNOTES_PATH_PREFIX}/health || exit 1 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flatnotes", 3 | "version": "5.5.1", 4 | "type": "module", 5 | "description": "A database-less note taking web app that utilises a flat folder of markdown files for storage.", 6 | "main": "client/index.html", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "build": "vite build", 10 | "watch": "vite build --watch", 11 | "dev": "vite" 12 | }, 13 | "author": "Adam Dullage", 14 | "license": "MIT", 15 | "dependencies": { 16 | "@jamescoyle/vue-icon": "0.1.2", 17 | "@mdi/js": "7.4.47", 18 | "@mdi/light-js": "0.2.63", 19 | "@toast-ui/editor": "3.2.2", 20 | "@toast-ui/editor-plugin-code-syntax-highlight": "3.1.0", 21 | "axios": "1.8.4", 22 | "mousetrap": "1.6.5", 23 | "pinia": "2.3.1", 24 | "primevue": "3.53.1", 25 | "vue": "3.5.13", 26 | "vue-router": "4.5.0" 27 | }, 28 | "devDependencies": { 29 | "@vitejs/plugin-vue": "5.2.3", 30 | "autoprefixer": "10.4.21", 31 | "postcss": "8.5.3", 32 | "prettier": "3.5.3", 33 | "prettier-plugin-tailwindcss": "0.6.11", 34 | "sass-embedded": "1.86.1", 35 | "tailwindcss": "3.4.17", 36 | "vite": "6.2.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: ["prettier-plugin-tailwindcss"], 3 | }; 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | line_length = 79 4 | src_paths = ["server"] 5 | -------------------------------------------------------------------------------- /server/api_messages.py: -------------------------------------------------------------------------------- 1 | login_failed = "Invalid login details." 2 | note_exists = "Cannot create note. A note with the same title already exists." 3 | note_not_found = "The specified note cannot be found." 4 | invalid_note_title = "The specified note title contains invalid characters." 5 | attachment_exists = ( 6 | "Cannot create attachment. An attachment with the same filename already " 7 | "exists." 8 | ) 9 | attachment_not_found = "The specified attachment cannot be found." 10 | invalid_attachment_filename = ( 11 | "The specified filename contains invalid characters." 12 | ) 13 | -------------------------------------------------------------------------------- /server/attachments/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from fastapi import UploadFile 4 | from fastapi.responses import FileResponse 5 | 6 | from .models import AttachmentCreateResponse 7 | 8 | 9 | class BaseAttachments(ABC): 10 | @abstractmethod 11 | def create(self, file: UploadFile) -> AttachmentCreateResponse: 12 | """Create a new attachment.""" 13 | pass 14 | 15 | @abstractmethod 16 | def get(self, filename: str) -> FileResponse: 17 | """Get a specific attachment.""" 18 | pass 19 | -------------------------------------------------------------------------------- /server/attachments/file_system/__init__.py: -------------------------------------------------------------------------------- 1 | from .file_system import FileSystemAttachments # noqa 2 | -------------------------------------------------------------------------------- /server/attachments/file_system/file_system.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import urllib.parse 4 | from datetime import datetime 5 | 6 | from fastapi import UploadFile 7 | from fastapi.responses import FileResponse 8 | 9 | from helpers import get_env, is_valid_filename 10 | 11 | from ..base import BaseAttachments 12 | from ..models import AttachmentCreateResponse 13 | 14 | 15 | class FileSystemAttachments(BaseAttachments): 16 | def __init__(self): 17 | self.base_path = get_env("FLATNOTES_PATH", mandatory=True) 18 | if not os.path.exists(self.base_path): 19 | raise NotADirectoryError( 20 | f"'{self.base_path}' is not a valid directory." 21 | ) 22 | self.storage_path = os.path.join(self.base_path, "attachments") 23 | os.makedirs(self.storage_path, exist_ok=True) 24 | 25 | def create(self, file: UploadFile) -> AttachmentCreateResponse: 26 | """Create a new attachment.""" 27 | is_valid_filename(file.filename) 28 | try: 29 | self._save_file(file) 30 | except FileExistsError: 31 | file.filename = self._datetime_suffix_filename(file.filename) 32 | self._save_file(file) 33 | return AttachmentCreateResponse( 34 | filename=file.filename, url=self._url_for_filename(file.filename) 35 | ) 36 | 37 | def get(self, filename: str) -> FileResponse: 38 | """Get a specific attachment.""" 39 | is_valid_filename(filename) 40 | filepath = os.path.join(self.storage_path, filename) 41 | if not os.path.isfile(filepath): 42 | raise FileNotFoundError(f"'{filename}' not found.") 43 | return FileResponse(filepath) 44 | 45 | def _save_file(self, file: UploadFile): 46 | filepath = os.path.join(self.storage_path, file.filename) 47 | with open(filepath, "xb") as f: 48 | shutil.copyfileobj(file.file, f) 49 | 50 | def _datetime_suffix_filename(self, filename: str) -> str: 51 | """Add a timestamp suffix to the filename.""" 52 | timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H-%M-%SZ") 53 | name, ext = os.path.splitext(filename) 54 | return f"{name}_{timestamp}{ext}" 55 | 56 | def _url_for_filename(self, filename: str) -> str: 57 | """Return the URL for the given filename.""" 58 | return f"attachments/{urllib.parse.quote(filename)}" 59 | -------------------------------------------------------------------------------- /server/attachments/models.py: -------------------------------------------------------------------------------- 1 | from helpers import CustomBaseModel 2 | 3 | 4 | class AttachmentCreateResponse(CustomBaseModel): 5 | filename: str 6 | url: str 7 | -------------------------------------------------------------------------------- /server/auth/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from .models import Login, Token 4 | 5 | 6 | class BaseAuth(ABC): 7 | @abstractmethod 8 | def login(self, data: Login) -> Token: 9 | """Login a user.""" 10 | pass 11 | 12 | @abstractmethod 13 | def authenticate(self, token: str) -> bool: 14 | """Authenticate a user.""" 15 | pass 16 | -------------------------------------------------------------------------------- /server/auth/local/__init__.py: -------------------------------------------------------------------------------- 1 | from .local import LocalAuth # noqa 2 | -------------------------------------------------------------------------------- /server/auth/local/local.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from base64 import b32encode 3 | from datetime import datetime, timedelta 4 | 5 | from fastapi import Depends, HTTPException, Request 6 | from fastapi.security import OAuth2PasswordBearer 7 | from jose import JWTError, jwt 8 | from pyotp import TOTP 9 | from pyotp.utils import build_uri 10 | from qrcode import QRCode 11 | 12 | from global_config import AuthType, GlobalConfig 13 | from helpers import get_env 14 | 15 | from ..base import BaseAuth 16 | from ..models import Login, Token 17 | 18 | global_config = GlobalConfig() 19 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/token", auto_error=False) 20 | 21 | 22 | class LocalAuth(BaseAuth): 23 | JWT_ALGORITHM = "HS256" 24 | 25 | def __init__(self) -> None: 26 | self.username = get_env("FLATNOTES_USERNAME", mandatory=True).lower() 27 | self.password = get_env("FLATNOTES_PASSWORD", mandatory=True) 28 | self.secret_key = get_env("FLATNOTES_SECRET_KEY", mandatory=True) 29 | self.session_expiry_days = get_env( 30 | "FLATNOTES_SESSION_EXPIRY_DAYS", default=30, cast_int=True 31 | ) 32 | 33 | # TOTP 34 | self.is_totp_enabled = False 35 | if global_config.auth_type == AuthType.TOTP: 36 | self.is_totp_enabled = True 37 | self.totp_key = get_env("FLATNOTES_TOTP_KEY", mandatory=True) 38 | self.totp_key = b32encode(self.totp_key.encode("utf-8")) 39 | self.totp = TOTP(self.totp_key) 40 | self.last_used_totp = None 41 | self._display_totp_enrolment() 42 | 43 | def login(self, data: Login) -> Token: 44 | # Check Username 45 | username_correct = secrets.compare_digest( 46 | self.username.lower(), data.username.lower() 47 | ) 48 | 49 | # Check Password & TOTP 50 | expected_password = self.password 51 | if self.is_totp_enabled: 52 | current_totp = self.totp.now() 53 | expected_password += current_totp 54 | password_correct = secrets.compare_digest( 55 | expected_password, data.password 56 | ) 57 | 58 | # Raise error if incorrect 59 | if not ( 60 | username_correct 61 | and password_correct 62 | # Prevent TOTP from being reused 63 | and ( 64 | self.is_totp_enabled is False 65 | or current_totp != self.last_used_totp 66 | ) 67 | ): 68 | raise ValueError("Incorrect login credentials.") 69 | if self.is_totp_enabled: 70 | self.last_used_totp = current_totp 71 | 72 | # Create Token 73 | access_token = self._create_access_token(data={"sub": self.username}) 74 | return Token(access_token=access_token) 75 | 76 | def authenticate( 77 | self, request: Request, token: str = Depends(oauth2_scheme) 78 | ): 79 | # If no token is found in the header, check the cookies 80 | if token is None: 81 | token = request.cookies.get("token") 82 | # Validate the token 83 | try: 84 | self._validate_token(token) 85 | except (JWTError, ValueError): 86 | raise HTTPException( 87 | status_code=401, 88 | detail="Invalid authentication credentials", 89 | headers={"WWW-Authenticate": "Bearer"}, 90 | ) 91 | 92 | def _validate_token(self, token: str) -> bool: 93 | if token is None: 94 | raise ValueError 95 | payload = jwt.decode( 96 | token, self.secret_key, algorithms=[self.JWT_ALGORITHM] 97 | ) 98 | username = payload.get("sub") 99 | if username is None or username.lower() != self.username: 100 | raise ValueError 101 | 102 | def _create_access_token(self, data: dict): 103 | to_encode = data.copy() 104 | expiry_datetime = datetime.utcnow() + timedelta( 105 | days=self.session_expiry_days 106 | ) 107 | to_encode.update({"exp": expiry_datetime}) 108 | encoded_jwt = jwt.encode( 109 | to_encode, self.secret_key, algorithm=self.JWT_ALGORITHM 110 | ) 111 | return encoded_jwt 112 | 113 | def _display_totp_enrolment(self): 114 | # Fix for #237. Remove padding as per spec: 115 | # https://github.com/google/google-authenticator/wiki/Key-Uri-Format#secret 116 | unpadded_secret = self.totp_key.rstrip(b"=") 117 | uri = build_uri(unpadded_secret, self.username, issuer="flatnotes") 118 | qr = QRCode() 119 | qr.add_data(uri) 120 | print( 121 | "\nScan this QR code with your TOTP app of choice", 122 | "e.g. Authy or Google Authenticator:", 123 | ) 124 | qr.print_ascii() 125 | print( 126 | f"Or manually enter this key: {self.totp.secret.decode('utf-8')}\n" 127 | ) 128 | -------------------------------------------------------------------------------- /server/auth/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | from helpers import CustomBaseModel 4 | 5 | 6 | class Login(CustomBaseModel): 7 | username: str 8 | password: str 9 | 10 | 11 | class Token(BaseModel): 12 | # Note: OAuth requires keys to be snake_case so we use the standard 13 | # BaseModel here 14 | access_token: str 15 | token_type: str = Field("bearer") 16 | -------------------------------------------------------------------------------- /server/global_config.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from enum import Enum 3 | 4 | from helpers import CustomBaseModel, get_env 5 | from logger import logger 6 | 7 | 8 | class GlobalConfig: 9 | def __init__(self) -> None: 10 | logger.debug("Loading global config...") 11 | self.auth_type: AuthType = self._load_auth_type() 12 | self.quick_access_hide: bool = self._quick_access_hide() 13 | self.quick_access_title: str = self._quick_access_title() 14 | self.quick_access_term: str = self._quick_access_term() 15 | self.quick_access_sort: str = self._quick_access_sort() 16 | self.quick_access_limit: int = self._quick_access_limit() 17 | self.path_prefix: str = self._load_path_prefix() 18 | 19 | def load_auth(self): 20 | if self.auth_type in (AuthType.NONE, AuthType.READ_ONLY): 21 | return None 22 | elif self.auth_type in (AuthType.PASSWORD, AuthType.TOTP): 23 | from auth.local import LocalAuth 24 | 25 | return LocalAuth() 26 | 27 | def load_note_storage(self): 28 | from notes.file_system import FileSystemNotes 29 | 30 | return FileSystemNotes() 31 | 32 | def load_attachment_storage(self): 33 | from attachments.file_system import FileSystemAttachments 34 | 35 | return FileSystemAttachments() 36 | 37 | def _load_auth_type(self): 38 | key = "FLATNOTES_AUTH_TYPE" 39 | auth_type = get_env( 40 | key, mandatory=False, default=AuthType.PASSWORD.value 41 | ) 42 | try: 43 | auth_type = AuthType(auth_type.lower()) 44 | except ValueError: 45 | logger.error( 46 | f"Invalid value '{auth_type}' for {key}. " 47 | + "Must be one of: " 48 | + ", ".join([auth_type.value for auth_type in AuthType]) 49 | + "." 50 | ) 51 | sys.exit(1) 52 | return auth_type 53 | 54 | def _quick_access_hide(self): 55 | key = "FLATNOTES_QUICK_ACCESS_HIDE" 56 | value = get_env(key, mandatory=False, default=False, cast_bool=True) 57 | if value is False: 58 | depricated_key = "FLATNOTES_HIDE_RECENTLY_MODIFIED" 59 | value = get_env( 60 | depricated_key, mandatory=False, default=False, cast_bool=True 61 | ) 62 | if value is True: 63 | logger.warning( 64 | f"{depricated_key} is depricated. Please use {key} instead." 65 | ) 66 | return value 67 | 68 | def _quick_access_title(self): 69 | key = "FLATNOTES_QUICK_ACCESS_TITLE" 70 | return get_env(key, mandatory=False, default="RECENTLY MODIFIED") 71 | 72 | def _quick_access_term(self): 73 | key = "FLATNOTES_QUICK_ACCESS_TERM" 74 | return get_env(key, mandatory=False, default="*") 75 | 76 | def _quick_access_sort(self): 77 | key = "FLATNOTES_QUICK_ACCESS_SORT" 78 | value = get_env(key, mandatory=False, default="lastModified") 79 | valid_values = ["score", "title", "lastModified"] 80 | if value not in valid_values: 81 | logger.error( 82 | f"Invalid value '{value}' for {key}. " 83 | + "Must be one of: " 84 | + ", ".join(valid_values) 85 | ) 86 | sys.exit(1) 87 | return value 88 | 89 | def _quick_access_limit(self): 90 | key = "FLATNOTES_QUICK_ACCESS_LIMIT" 91 | return get_env(key, mandatory=False, default=4, cast_int=True) 92 | 93 | def _load_path_prefix(self): 94 | key = "FLATNOTES_PATH_PREFIX" 95 | value = get_env(key, mandatory=False, default="") 96 | if value and (not value.startswith("/") or value.endswith("/")): 97 | logger.error( 98 | f"Invalid value '{value}' for {key}. " 99 | + "Must start with '/' and not end with '/'." 100 | ) 101 | sys.exit(1) 102 | return value 103 | 104 | 105 | class AuthType(str, Enum): 106 | NONE = "none" 107 | READ_ONLY = "read_only" 108 | PASSWORD = "password" 109 | TOTP = "totp" 110 | 111 | 112 | class GlobalConfigResponseModel(CustomBaseModel): 113 | auth_type: AuthType 114 | quick_access_hide: bool 115 | quick_access_title: str 116 | quick_access_term: str 117 | quick_access_sort: str 118 | quick_access_limit: int 119 | -------------------------------------------------------------------------------- /server/helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | 5 | from pydantic import BaseModel 6 | 7 | from logger import logger 8 | 9 | 10 | def camel_case(snake_case_str: str) -> str: 11 | """Return the declared snake_case string in camelCase.""" 12 | parts = [part for part in snake_case_str.split("_") if part != ""] 13 | return parts[0] + "".join(part.title() for part in parts[1:]) 14 | 15 | 16 | def is_valid_filename(value): 17 | """Raise ValueError if the declared string contains any of the following 18 | characters: <>:"/\\|?*""" 19 | invalid_chars = r'<>:"/\|?*' 20 | if any(invalid_char in value for invalid_char in invalid_chars): 21 | raise ValueError( 22 | "title cannot include any of the following characters: " 23 | + invalid_chars 24 | ) 25 | return value 26 | 27 | 28 | def strip_whitespace(value): 29 | """Return the declared string with leading and trailing whitespace 30 | removed.""" 31 | return value.strip() 32 | 33 | 34 | def get_env( 35 | key, mandatory=False, default=None, cast_int=False, cast_bool=False 36 | ): 37 | """Get an environment variable. If `mandatory` is True and environment 38 | variable isn't set, exit the program""" 39 | value = os.environ.get(key) 40 | if mandatory and not value: 41 | logger.error(f"Environment variable {key} must be set.") 42 | sys.exit(1) 43 | if not mandatory and not value: 44 | return default 45 | if cast_int: 46 | try: 47 | value = int(value) 48 | except (TypeError, ValueError): 49 | logger.error(f"Invalid value '{value}' for {key}.") 50 | sys.exit(1) 51 | if cast_bool: 52 | value = value.lower() 53 | if value == "true": 54 | value = True 55 | elif value == "false": 56 | value = False 57 | else: 58 | logger.error(f"Invalid value '{value}' for {key}.") 59 | sys.exit(1) 60 | return value 61 | 62 | 63 | def replace_base_href(html_file, path_prefix): 64 | """Replace the href value for the base element in an HTML file.""" 65 | base_path = path_prefix + "/" 66 | logger.info( 67 | f"Replacing href value for base element in '{html_file}' " 68 | + f"with '{base_path}'." 69 | ) 70 | with open(html_file, "r", encoding="utf-8") as f: 71 | html = f.read() 72 | pattern = r'( bool: 20 | return ( 21 | record.args 22 | and len(record.args) >= 3 23 | and record.args[2] != "/health" 24 | ) 25 | 26 | 27 | uvicorn_logger = logging.getLogger("uvicorn.access") 28 | uvicorn_logger.addFilter(HealthEndpointFilter()) 29 | for handler in uvicorn_logger.handlers: 30 | handler.setFormatter(formatter) 31 | uvicorn_logger.setLevel(log_level) 32 | -------------------------------------------------------------------------------- /server/main.py: -------------------------------------------------------------------------------- 1 | from typing import List, Literal 2 | 3 | from fastapi import APIRouter, Depends, FastAPI, HTTPException, UploadFile 4 | from fastapi.responses import HTMLResponse 5 | from fastapi.staticfiles import StaticFiles 6 | 7 | import api_messages 8 | from attachments.base import BaseAttachments 9 | from attachments.models import AttachmentCreateResponse 10 | from auth.base import BaseAuth 11 | from auth.models import Login, Token 12 | from global_config import AuthType, GlobalConfig, GlobalConfigResponseModel 13 | from helpers import replace_base_href 14 | from notes.base import BaseNotes 15 | from notes.models import Note, NoteCreate, NoteUpdate, SearchResult 16 | 17 | global_config = GlobalConfig() 18 | auth: BaseAuth = global_config.load_auth() 19 | note_storage: BaseNotes = global_config.load_note_storage() 20 | attachment_storage: BaseAttachments = global_config.load_attachment_storage() 21 | auth_deps = [Depends(auth.authenticate)] if auth else [] 22 | router = APIRouter() 23 | app = FastAPI( 24 | docs_url=global_config.path_prefix + "/docs", 25 | openapi_url=global_config.path_prefix + "/openapi.json", 26 | ) 27 | replace_base_href("client/dist/index.html", global_config.path_prefix) 28 | 29 | 30 | # region UI 31 | @router.get("/", include_in_schema=False) 32 | @router.get("/login", include_in_schema=False) 33 | @router.get("/search", include_in_schema=False) 34 | @router.get("/new", include_in_schema=False) 35 | @router.get("/note/{title}", include_in_schema=False) 36 | def root(title: str = ""): 37 | with open("client/dist/index.html", "r", encoding="utf-8") as f: 38 | html = f.read() 39 | return HTMLResponse(content=html) 40 | 41 | 42 | # endregion 43 | 44 | 45 | # region Login 46 | if global_config.auth_type not in [AuthType.NONE, AuthType.READ_ONLY]: 47 | 48 | @router.post("/api/token", response_model=Token) 49 | def token(data: Login): 50 | try: 51 | return auth.login(data) 52 | except ValueError: 53 | raise HTTPException( 54 | status_code=401, detail=api_messages.login_failed 55 | ) 56 | 57 | 58 | # endregion 59 | 60 | 61 | # region Notes 62 | # Get Note 63 | @router.get( 64 | "/api/notes/{title}", 65 | dependencies=auth_deps, 66 | response_model=Note, 67 | ) 68 | def get_note(title: str): 69 | """Get a specific note.""" 70 | try: 71 | return note_storage.get(title) 72 | except ValueError: 73 | raise HTTPException( 74 | status_code=400, detail=api_messages.invalid_note_title 75 | ) 76 | except FileNotFoundError: 77 | raise HTTPException(404, api_messages.note_not_found) 78 | 79 | 80 | if global_config.auth_type != AuthType.READ_ONLY: 81 | 82 | # Create Note 83 | @router.post( 84 | "/api/notes", 85 | dependencies=auth_deps, 86 | response_model=Note, 87 | ) 88 | def post_note(note: NoteCreate): 89 | """Create a new note.""" 90 | try: 91 | return note_storage.create(note) 92 | except ValueError: 93 | raise HTTPException( 94 | status_code=400, 95 | detail=api_messages.invalid_note_title, 96 | ) 97 | except FileExistsError: 98 | raise HTTPException( 99 | status_code=409, detail=api_messages.note_exists 100 | ) 101 | 102 | # Update Note 103 | @router.patch( 104 | "/api/notes/{title}", 105 | dependencies=auth_deps, 106 | response_model=Note, 107 | ) 108 | def patch_note(title: str, data: NoteUpdate): 109 | try: 110 | return note_storage.update(title, data) 111 | except ValueError: 112 | raise HTTPException( 113 | status_code=400, 114 | detail=api_messages.invalid_note_title, 115 | ) 116 | except FileExistsError: 117 | raise HTTPException( 118 | status_code=409, detail=api_messages.note_exists 119 | ) 120 | except FileNotFoundError: 121 | raise HTTPException(404, api_messages.note_not_found) 122 | 123 | # Delete Note 124 | @router.delete( 125 | "/api/notes/{title}", 126 | dependencies=auth_deps, 127 | response_model=None, 128 | ) 129 | def delete_note(title: str): 130 | try: 131 | note_storage.delete(title) 132 | except ValueError: 133 | raise HTTPException( 134 | status_code=400, 135 | detail=api_messages.invalid_note_title, 136 | ) 137 | except FileNotFoundError: 138 | raise HTTPException(404, api_messages.note_not_found) 139 | 140 | 141 | # endregion 142 | 143 | 144 | # region Search 145 | @router.get( 146 | "/api/search", 147 | dependencies=auth_deps, 148 | response_model=List[SearchResult], 149 | ) 150 | def search( 151 | term: str, 152 | sort: Literal["score", "title", "lastModified"] = "score", 153 | order: Literal["asc", "desc"] = "desc", 154 | limit: int = None, 155 | ): 156 | """Perform a full text search on all notes.""" 157 | if sort == "lastModified": 158 | sort = "last_modified" 159 | return note_storage.search(term, sort=sort, order=order, limit=limit) 160 | 161 | 162 | @router.get( 163 | "/api/tags", 164 | dependencies=auth_deps, 165 | response_model=List[str], 166 | ) 167 | def get_tags(): 168 | """Get a list of all indexed tags.""" 169 | return note_storage.get_tags() 170 | 171 | 172 | # endregion 173 | 174 | 175 | # region Config 176 | @router.get("/api/config", response_model=GlobalConfigResponseModel) 177 | def get_config(): 178 | """Retrieve server-side config required for the UI.""" 179 | return GlobalConfigResponseModel( 180 | auth_type=global_config.auth_type, 181 | quick_access_hide=global_config.quick_access_hide, 182 | quick_access_title=global_config.quick_access_title, 183 | quick_access_term=global_config.quick_access_term, 184 | quick_access_sort=global_config.quick_access_sort, 185 | quick_access_limit=global_config.quick_access_limit, 186 | ) 187 | 188 | 189 | # endregion 190 | 191 | 192 | # region Attachments 193 | # Get Attachment 194 | @router.get( 195 | "/api/attachments/{filename}", 196 | dependencies=auth_deps, 197 | ) 198 | # Include a secondary route used to create relative URLs that can be used 199 | # outside the context of flatnotes (e.g. "/attachments/image.jpg"). 200 | @router.get( 201 | "/attachments/{filename}", 202 | dependencies=auth_deps, 203 | include_in_schema=False, 204 | ) 205 | def get_attachment(filename: str): 206 | """Download an attachment.""" 207 | try: 208 | return attachment_storage.get(filename) 209 | except ValueError: 210 | raise HTTPException( 211 | status_code=400, 212 | detail=api_messages.invalid_attachment_filename, 213 | ) 214 | except FileNotFoundError: 215 | raise HTTPException( 216 | status_code=404, detail=api_messages.attachment_not_found 217 | ) 218 | 219 | 220 | if global_config.auth_type != AuthType.READ_ONLY: 221 | 222 | # Create Attachment 223 | @router.post( 224 | "/api/attachments", 225 | dependencies=auth_deps, 226 | response_model=AttachmentCreateResponse, 227 | ) 228 | def post_attachment(file: UploadFile): 229 | """Upload an attachment.""" 230 | try: 231 | return attachment_storage.create(file) 232 | except ValueError: 233 | raise HTTPException( 234 | status_code=400, 235 | detail=api_messages.invalid_attachment_filename, 236 | ) 237 | except FileExistsError: 238 | raise HTTPException(409, api_messages.attachment_exists) 239 | 240 | 241 | # endregion 242 | 243 | 244 | # region Healthcheck 245 | @router.get("/health") 246 | def healthcheck() -> str: 247 | """A lightweight endpoint that simply returns 'OK' to indicate the server 248 | is running.""" 249 | return "OK" 250 | 251 | 252 | # endregion 253 | 254 | app.include_router(router, prefix=global_config.path_prefix) 255 | app.mount( 256 | global_config.path_prefix, 257 | StaticFiles(directory="client/dist"), 258 | name="dist", 259 | ) 260 | -------------------------------------------------------------------------------- /server/notes/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Literal 3 | 4 | from .models import Note, NoteCreate, NoteUpdate, SearchResult 5 | 6 | 7 | class BaseNotes(ABC): 8 | @abstractmethod 9 | def create(self, data: NoteCreate) -> Note: 10 | """Create a new note.""" 11 | pass 12 | 13 | @abstractmethod 14 | def get(self, title: str) -> Note: 15 | """Get a specific note.""" 16 | pass 17 | 18 | @abstractmethod 19 | def update(self, title: str, new_data: NoteUpdate) -> Note: 20 | """Update a specific note.""" 21 | pass 22 | 23 | @abstractmethod 24 | def delete(self, title: str) -> None: 25 | """Delete a specific note.""" "" 26 | pass 27 | 28 | @abstractmethod 29 | def search( 30 | self, 31 | term: str, 32 | sort: Literal["score", "title", "last_modified"] = "score", 33 | order: Literal["asc", "desc"] = "desc", 34 | limit: int = None, 35 | ) -> list[SearchResult]: 36 | """Search for notes.""" 37 | pass 38 | 39 | @abstractmethod 40 | def get_tags(self) -> list[str]: 41 | """Get a list of all indexed tags.""" 42 | pass 43 | -------------------------------------------------------------------------------- /server/notes/file_system/__init__.py: -------------------------------------------------------------------------------- 1 | from .file_system import FileSystemNotes 2 | -------------------------------------------------------------------------------- /server/notes/file_system/file_system.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | import re 4 | import shutil 5 | import time 6 | from datetime import datetime 7 | from typing import List, Literal, Set, Tuple 8 | 9 | import whoosh 10 | from whoosh import writing 11 | from whoosh.analysis import CharsetFilter, StemmingAnalyzer 12 | from whoosh.fields import DATETIME, ID, KEYWORD, TEXT, SchemaClass 13 | from whoosh.highlight import ContextFragmenter, WholeFragmenter 14 | from whoosh.index import Index, LockError 15 | from whoosh.qparser import MultifieldParser 16 | from whoosh.qparser.dateparse import DateParserPlugin 17 | from whoosh.query import Every 18 | from whoosh.searching import Hit 19 | from whoosh.support.charset import accent_map 20 | 21 | from helpers import get_env, is_valid_filename 22 | from logger import logger 23 | 24 | from ..base import BaseNotes 25 | from ..models import Note, NoteCreate, NoteUpdate, SearchResult 26 | 27 | MARKDOWN_EXT = ".md" 28 | INDEX_SCHEMA_VERSION = "5" 29 | 30 | StemmingFoldingAnalyzer = StemmingAnalyzer() | CharsetFilter(accent_map) 31 | 32 | 33 | class IndexSchema(SchemaClass): 34 | filename = ID(unique=True, stored=True) 35 | last_modified = DATETIME(stored=True, sortable=True) 36 | title = TEXT( 37 | field_boost=2.0, analyzer=StemmingFoldingAnalyzer, sortable=True 38 | ) 39 | content = TEXT(analyzer=StemmingFoldingAnalyzer) 40 | tags = KEYWORD(lowercase=True, field_boost=2.0) 41 | 42 | 43 | class FileSystemNotes(BaseNotes): 44 | TAGS_RE = re.compile(r"(?:(?<=^#)|(?<=\s#))[a-zA-Z0-9_-]+(?=\s|$)") 45 | CODEBLOCK_RE = re.compile(r"`{1,3}.*?`{1,3}", re.DOTALL) 46 | TAGS_WITH_HASH_RE = re.compile( 47 | r"(?:(?<=^)|(?<=\s))#[a-zA-Z0-9_-]+(?=\s|$)" 48 | ) 49 | 50 | def __init__(self): 51 | self.storage_path = get_env("FLATNOTES_PATH", mandatory=True) 52 | if not os.path.exists(self.storage_path): 53 | raise NotADirectoryError( 54 | f"'{self.storage_path}' is not a valid directory." 55 | ) 56 | self.index = self._load_index() 57 | self._sync_index_with_retry(optimize=True) 58 | 59 | def create(self, data: NoteCreate) -> Note: 60 | """Create a new note.""" 61 | filepath = self._path_from_title(data.title) 62 | self._write_file(filepath, data.content) 63 | return Note( 64 | title=data.title, 65 | content=data.content, 66 | last_modified=os.path.getmtime(filepath), 67 | ) 68 | 69 | def get(self, title: str) -> Note: 70 | """Get a specific note.""" 71 | is_valid_filename(title) 72 | filepath = self._path_from_title(title) 73 | content = self._read_file(filepath) 74 | return Note( 75 | title=title, 76 | content=content, 77 | last_modified=os.path.getmtime(filepath), 78 | ) 79 | 80 | def update(self, title: str, data: NoteUpdate) -> Note: 81 | """Update a specific note.""" 82 | is_valid_filename(title) 83 | filepath = self._path_from_title(title) 84 | if data.new_title is not None: 85 | new_filepath = self._path_from_title(data.new_title) 86 | if filepath != new_filepath and os.path.isfile(new_filepath): 87 | raise FileExistsError( 88 | f"Failed to rename. '{data.new_title}' already exists." 89 | ) 90 | os.rename(filepath, new_filepath) 91 | title = data.new_title 92 | filepath = new_filepath 93 | if data.new_content is not None: 94 | self._write_file(filepath, data.new_content, overwrite=True) 95 | content = data.new_content 96 | else: 97 | content = self._read_file(filepath) 98 | return Note( 99 | title=title, 100 | content=content, 101 | last_modified=os.path.getmtime(filepath), 102 | ) 103 | 104 | def delete(self, title: str) -> None: 105 | """Delete a specific note.""" 106 | is_valid_filename(title) 107 | filepath = self._path_from_title(title) 108 | os.remove(filepath) 109 | 110 | def search( 111 | self, 112 | term: str, 113 | sort: Literal["score", "title", "last_modified"] = "score", 114 | order: Literal["asc", "desc"] = "desc", 115 | limit: int = None, 116 | ) -> Tuple[SearchResult, ...]: 117 | """Search the index for the given term.""" 118 | self._sync_index_with_retry() 119 | term = self._pre_process_search_term(term) 120 | with self.index.searcher() as searcher: 121 | # Parse Query 122 | if term == "*": 123 | query = Every() 124 | else: 125 | parser = MultifieldParser( 126 | self._fieldnames_for_term(term), self.index.schema 127 | ) 128 | parser.add_plugin(DateParserPlugin()) 129 | query = parser.parse(term) 130 | 131 | # Determine Sort By 132 | # Note: For the 'sort' option, "score" is converted to None as 133 | # that is the default for searches anyway and it's quicker for 134 | # Whoosh if you specify None. 135 | sort = sort if sort in ["title", "last_modified"] else None 136 | 137 | # Determine Sort Direction 138 | # Note: Confusingly, when sorting by 'score', reverse = True means 139 | # asc so we have to flip the logic for that case! 140 | reverse = order == "desc" 141 | if sort is None: 142 | reverse = not reverse 143 | 144 | # Run Search 145 | results = searcher.search( 146 | query, 147 | sortedby=sort, 148 | reverse=reverse, 149 | limit=limit, 150 | terms=True, 151 | ) 152 | return tuple(self._search_result_from_hit(hit) for hit in results) 153 | 154 | def get_tags(self) -> list[str]: 155 | """Return a list of all indexed tags. Note: Tags no longer in use will 156 | only be cleared when the index is next optimized.""" 157 | self._sync_index_with_retry() 158 | with self.index.reader() as reader: 159 | tags = reader.field_terms("tags") 160 | return [tag for tag in tags] 161 | 162 | @property 163 | def _index_path(self): 164 | return os.path.join(self.storage_path, ".flatnotes") 165 | 166 | def _path_from_title(self, title: str) -> str: 167 | return os.path.join(self.storage_path, title + MARKDOWN_EXT) 168 | 169 | def _get_by_filename(self, filename: str) -> Note: 170 | """Get a note by its filename.""" 171 | return self.get(self._strip_ext(filename)) 172 | 173 | def _load_index(self) -> Index: 174 | """Load the note index or create new if not exists.""" 175 | index_dir_exists = os.path.exists(self._index_path) 176 | if index_dir_exists and whoosh.index.exists_in( 177 | self._index_path, indexname=INDEX_SCHEMA_VERSION 178 | ): 179 | logger.info("Loading existing index") 180 | return whoosh.index.open_dir( 181 | self._index_path, indexname=INDEX_SCHEMA_VERSION 182 | ) 183 | else: 184 | if index_dir_exists: 185 | logger.info("Deleting outdated index") 186 | self._clear_dir(self._index_path) 187 | else: 188 | os.mkdir(self._index_path) 189 | logger.info("Creating new index") 190 | return whoosh.index.create_in( 191 | self._index_path, IndexSchema, indexname=INDEX_SCHEMA_VERSION 192 | ) 193 | 194 | @classmethod 195 | def _extract_tags(cls, content) -> Tuple[str, Set[str]]: 196 | """Strip tags from the given content and return a tuple consisting of: 197 | 198 | - The content without the tags. 199 | - A set of tags converted to lowercase.""" 200 | content_ex_codeblock = re.sub(cls.CODEBLOCK_RE, "", content) 201 | _, tags = cls._re_extract(cls.TAGS_RE, content_ex_codeblock) 202 | content_ex_tags, _ = cls._re_extract(cls.TAGS_RE, content) 203 | try: 204 | tags = [tag.lower() for tag in tags] 205 | return (content_ex_tags, set(tags)) 206 | except IndexError: 207 | return (content, set()) 208 | 209 | def _add_note_to_index( 210 | self, writer: writing.IndexWriter, note: Note 211 | ) -> None: 212 | """Add a Note object to the index using the given writer. If the 213 | filename already exists in the index an update will be performed 214 | instead.""" 215 | content_ex_tags, tag_set = self._extract_tags(note.content) 216 | tag_string = " ".join(tag_set) 217 | writer.update_document( 218 | filename=note.title + MARKDOWN_EXT, 219 | last_modified=datetime.fromtimestamp(note.last_modified), 220 | title=note.title, 221 | content=content_ex_tags, 222 | tags=tag_string, 223 | ) 224 | 225 | def _list_all_note_filenames(self) -> List[str]: 226 | """Return a list of all note filenames.""" 227 | return [ 228 | os.path.split(filepath)[1] 229 | for filepath in glob.glob( 230 | os.path.join(self.storage_path, "*" + MARKDOWN_EXT) 231 | ) 232 | ] 233 | 234 | def _sync_index(self, optimize: bool = False, clean: bool = False) -> None: 235 | """Synchronize the index with the notes directory. 236 | Specify clean=True to completely rebuild the index""" 237 | indexed = set() 238 | writer = self.index.writer() 239 | if clean: 240 | writer.mergetype = writing.CLEAR # Clear the index 241 | with self.index.searcher() as searcher: 242 | for idx_note in searcher.all_stored_fields(): 243 | idx_filename = idx_note["filename"] 244 | idx_filepath = os.path.join(self.storage_path, idx_filename) 245 | # Delete missing 246 | if not os.path.exists(idx_filepath): 247 | writer.delete_by_term("filename", idx_filename) 248 | logger.info(f"'{idx_filename}' removed from index") 249 | # Update modified 250 | elif ( 251 | datetime.fromtimestamp(os.path.getmtime(idx_filepath)) 252 | != idx_note["last_modified"] 253 | ): 254 | logger.info(f"'{idx_filename}' updated") 255 | self._add_note_to_index( 256 | writer, self._get_by_filename(idx_filename) 257 | ) 258 | indexed.add(idx_filename) 259 | # Ignore already indexed 260 | else: 261 | indexed.add(idx_filename) 262 | # Add new 263 | for filename in self._list_all_note_filenames(): 264 | if filename not in indexed: 265 | self._add_note_to_index( 266 | writer, self._get_by_filename(filename) 267 | ) 268 | logger.info(f"'{filename}' added to index") 269 | writer.commit(optimize=optimize) 270 | logger.info("Index synchronized") 271 | 272 | def _sync_index_with_retry( 273 | self, 274 | optimize: bool = False, 275 | clean: bool = False, 276 | max_retries: int = 8, 277 | retry_delay: float = 0.25, 278 | ) -> None: 279 | for _ in range(max_retries): 280 | try: 281 | self._sync_index(optimize=optimize, clean=clean) 282 | return 283 | except LockError: 284 | logger.warning(f"Index locked, retrying in {retry_delay}s") 285 | time.sleep(retry_delay) 286 | logger.error(f"Failed to sync index after {max_retries} retries") 287 | 288 | @classmethod 289 | def _pre_process_search_term(cls, term): 290 | term = term.strip() 291 | # Replace "#tagname" with "tags:tagname" 292 | term = re.sub( 293 | cls.TAGS_WITH_HASH_RE, 294 | lambda tag: "tags:" + tag.group(0)[1:], 295 | term, 296 | ) 297 | return term 298 | 299 | @staticmethod 300 | def _re_extract(pattern, string) -> Tuple[str, List[str]]: 301 | """Similar to re.sub but returns a tuple of: 302 | 303 | - `string` with matches removed 304 | - list of matches""" 305 | matches = [] 306 | text = re.sub(pattern, lambda tag: matches.append(tag.group()), string) 307 | return (text, matches) 308 | 309 | @staticmethod 310 | def _strip_ext(filename): 311 | """Return the given filename without the extension.""" 312 | return os.path.splitext(filename)[0] 313 | 314 | @staticmethod 315 | def _clear_dir(path): 316 | """Delete all contents of the given directory.""" 317 | for item in os.listdir(path): 318 | item_path = os.path.join(path, item) 319 | if os.path.isfile(item_path): 320 | os.remove(item_path) 321 | elif os.path.isdir(item_path): 322 | shutil.rmtree(item_path) 323 | 324 | def _search_result_from_hit(self, hit: Hit): 325 | matched_fields = self._get_matched_fields(hit.matched_terms()) 326 | 327 | title = self._strip_ext(hit["filename"]) 328 | last_modified = hit["last_modified"].timestamp() 329 | 330 | # If the search was ordered using a text field then hit.score is the 331 | # value of that field. This isn't useful so only set self._score if it 332 | # is a float. 333 | score = hit.score if type(hit.score) is float else None 334 | 335 | if "title" in matched_fields: 336 | hit.results.fragmenter = WholeFragmenter() 337 | title_highlights = hit.highlights("title", text=title) 338 | else: 339 | title_highlights = None 340 | 341 | if "content" in matched_fields: 342 | hit.results.fragmenter = ContextFragmenter() 343 | content = self._read_file(self._path_from_title(title)) 344 | content_ex_tags, _ = FileSystemNotes._extract_tags(content) 345 | content_highlights = hit.highlights( 346 | "content", 347 | text=content_ex_tags, 348 | ) 349 | else: 350 | content_highlights = None 351 | 352 | tag_matches = ( 353 | [field[1] for field in hit.matched_terms() if field[0] == "tags"] 354 | if "tags" in matched_fields 355 | else None 356 | ) 357 | 358 | return SearchResult( 359 | title=title, 360 | last_modified=last_modified, 361 | score=score, 362 | title_highlights=title_highlights, 363 | content_highlights=content_highlights, 364 | tag_matches=tag_matches, 365 | ) 366 | 367 | def _fieldnames_for_term(self, term: str) -> List[str]: 368 | """Return a list of field names to search based on the given term. If 369 | the term includes a phrase then only search title and content. If the 370 | term does not include a phrase then also search tags.""" 371 | fields = ["title", "content"] 372 | if '"' not in term: 373 | # If the term does not include a phrase then also search tags 374 | fields.append("tags") 375 | return fields 376 | 377 | @staticmethod 378 | def _get_matched_fields(matched_terms): 379 | """Return a set of matched fields from a set of ('field', 'term') " 380 | "tuples generated by whoosh.searching.Hit.matched_terms().""" 381 | return set([matched_term[0] for matched_term in matched_terms]) 382 | 383 | @staticmethod 384 | def _read_file(filepath: str): 385 | logger.debug(f"Reading from '{filepath}'") 386 | with open(filepath, "r") as f: 387 | content = f.read() 388 | return content 389 | 390 | @staticmethod 391 | def _write_file(filepath: str, content: str, overwrite: bool = False): 392 | logger.debug(f"Writing to '{filepath}'") 393 | with open(filepath, "w" if overwrite else "x") as f: 394 | f.write(content) 395 | -------------------------------------------------------------------------------- /server/notes/models.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic import Field 4 | from pydantic.functional_validators import AfterValidator 5 | from typing_extensions import Annotated 6 | 7 | from helpers import CustomBaseModel, is_valid_filename, strip_whitespace 8 | 9 | 10 | class NoteBase(CustomBaseModel): 11 | title: str 12 | 13 | 14 | class NoteCreate(CustomBaseModel): 15 | title: Annotated[ 16 | str, 17 | AfterValidator(strip_whitespace), 18 | AfterValidator(is_valid_filename), 19 | ] 20 | content: Optional[str] = Field(None) 21 | 22 | 23 | class Note(CustomBaseModel): 24 | title: str 25 | content: Optional[str] = Field(None) 26 | last_modified: float 27 | 28 | 29 | class NoteUpdate(CustomBaseModel): 30 | new_title: Annotated[ 31 | Optional[str], 32 | AfterValidator(strip_whitespace), 33 | AfterValidator(is_valid_filename), 34 | ] = Field(None) 35 | new_content: Optional[str] = Field(None) 36 | 37 | 38 | class SearchResult(CustomBaseModel): 39 | title: str 40 | last_modified: float 41 | 42 | score: Optional[float] = Field(None) 43 | title_highlights: Optional[str] = Field(None) 44 | content_highlights: Optional[str] = Field(None) 45 | tag_matches: Optional[List[str]] = Field(None) 46 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | 3 | import colors from "tailwindcss/colors"; 4 | 5 | export default { 6 | content: ["client/**/*.{html,js,vue}"], 7 | darkMode: "selector", 8 | theme: { 9 | fontFamily: { 10 | sans: ["Poppins", "sans-serif"], 11 | }, 12 | screens: { 13 | sm: "640px", 14 | md: "768px", 15 | lg: "1024px", 16 | }, 17 | extend: { 18 | colors: { 19 | // Dynamic 20 | "theme-brand": "rgb(var(--theme-brand) / )", 21 | "theme-background": "rgb(var(--theme-background) / )", 22 | "theme-background-elevated": 23 | "rgb(var(--theme-background-elevated) / )", 24 | "theme-text": "rgb(var(--theme-text) / )", 25 | "theme-text-muted": "rgb(var(--theme-text-muted) / )", 26 | "theme-text-very-muted": 27 | "rgb(var(--theme-text-very-muted) / )", 28 | "theme-shadow": "rgb(var(--theme-shadow) / )", 29 | "theme-border": "rgb(var(--theme-border) / )", 30 | // Static 31 | "theme-success": colors.emerald[600], 32 | "theme-danger": colors.rose[600], 33 | }, 34 | }, 35 | }, 36 | plugins: [], 37 | }; 38 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | 4 | const devApiUrl = "http://127.0.0.1:8000"; 5 | 6 | export default defineConfig({ 7 | plugins: [vue()], 8 | root: "client", 9 | base: "", 10 | server: { 11 | // Note: The FLATNOTES_PATH_PREFIX environment variable is not supported by the dev server 12 | port: 8080, 13 | proxy: { 14 | "/api/": { 15 | target: devApiUrl, 16 | changeOrigin: true, 17 | }, 18 | "/attachments/": { 19 | target: devApiUrl, 20 | changeOrigin: true, 21 | }, 22 | "/docs": { 23 | target: devApiUrl, 24 | changeOrigin: true, 25 | }, 26 | "/openapi.json": { 27 | target: devApiUrl, 28 | changeOrigin: true, 29 | }, 30 | "/health": { 31 | target: devApiUrl, 32 | changeOrigin: true, 33 | }, 34 | }, 35 | }, 36 | }); 37 | --------------------------------------------------------------------------------