├── .dockerignore ├── .gitignore ├── .npmrc ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── configs ├── tsconfig.json └── vitest-environment-obsync │ ├── .gitignore │ ├── package.json │ ├── tsconfig.json │ └── vitest-environment-obsync.ts ├── docker-compose.yaml ├── docs ├── motivation.md ├── setting-up-the-server.md └── using-the-plugin.md ├── eslint.config.mjs ├── package.json ├── plugin ├── .eslintignore ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── commands │ └── deleteUser.ts ├── components │ └── modals.ts ├── esbuild.config.mjs ├── eslint.config.mjs ├── main.ts ├── manifest.json ├── package.json ├── styles.css ├── tsconfig.json ├── types.ts ├── utils │ ├── extractContent.ts │ ├── refreshToken.ts │ └── writeNodeToFile.ts ├── version-bump.mjs └── versions.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── server ├── .gitignore ├── LICENCE ├── __tests__ │ ├── mockData │ │ ├── nodes.ts │ │ ├── users.ts │ │ └── vaults.ts │ ├── setupDb.ts │ └── user.test.ts ├── controllers │ ├── blog.ts │ ├── index.ts │ ├── login.ts │ ├── logout.ts │ ├── refreshToken.ts │ ├── user.ts │ └── vault.ts ├── db │ ├── base_schema.sql │ ├── index.ts │ ├── orm.ts │ └── schema.ts ├── eslint.config.mjs ├── middleware │ ├── loginMiddleware.ts │ └── verifyAuthMiddleware.ts ├── package.json ├── routes │ └── index.ts ├── server.ts ├── tsconfig.json ├── types.d.ts ├── utils │ ├── clearCookies.ts │ ├── consts.ts │ ├── createOrUpdateNodes.ts │ ├── generateToken.ts │ └── logger.ts └── vite.config.js └── shell.nix /.dockerignore: -------------------------------------------------------------------------------- 1 | docs 2 | node_modules 3 | .prettierrc 4 | .gitignore 5 | shell.nix 6 | render.yaml 7 | plugin 8 | .direnv 9 | pruned 10 | .env 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *debug* 2 | node_modules 3 | .env* 4 | .obsidian 5 | .DS_Store 6 | .direnv 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "useTabs": true 4 | } 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS base 2 | ENV PNPM_HOME="/pnpm" 3 | ENV PATH="$PNPM_HOME:$PATH" 4 | RUN corepack enable 5 | COPY . /app 6 | WORKDIR /app 7 | 8 | FROM base AS install 9 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install 10 | 11 | FROM install AS test 12 | RUN pnpm -F './configs/vitest-environment-obsync' build 13 | CMD ["pnpm", "test:server"] 14 | 15 | FROM install AS dev 16 | CMD ["pnpm", "dev:server"] 17 | 18 | 19 | FROM base AS build 20 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 21 | RUN pnpm -F obsidian-sync-server build:prod 22 | 23 | FROM build AS pruned 24 | RUN pnpm -F obsidian-sync-server --prod deploy pruned 25 | 26 | # entrypoint container with dumb-init 27 | FROM node:22-alpine 28 | ENV PNPM_HOME="/pnpm" 29 | ENV PATH="$PNPM_HOME:$PATH" 30 | RUN corepack enable 31 | WORKDIR /app 32 | RUN apk update && apk add --no-cache dumb-init 33 | RUN pnpm install -g pm2 34 | ENV NODE_ENV=production 35 | ENV PORT=8000 36 | ENV DATABASE_URL=$DATABASE_URL 37 | COPY --from=pruned --chown=node:node /app/pruned /app 38 | EXPOSE 8000 39 | 40 | ENTRYPOINT ["/usr/bin/dumb-init", "--"] 41 | CMD [ "pm2-runtime", "--", "./build/server.js" ] 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Coby Sher 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian.md DIY Sync Server+Plugin 2 | 3 | ## Prerequisites 4 | 5 | This is a monorepo made with pnpm workspaces. 6 | 7 | - pnpm 8 | - node 9 | - npm 10 | - podman 11 | 12 | ### Technologies Used 13 | 14 | - TypeScript 15 | - Express 16 | - sqlite 17 | - obsidian-sample-plugin (used for the plugin template) 18 | - Docker/podman 19 | 20 | ## To get started for local development: 21 | 22 | 1. Clone or fork this repo 23 | 1. run `pnpm install` in your terminal where the repo is cloned 24 | 1. To install the plugin into obsidian, I recommend using a symlink 25 | 1. Symlink the monorepo's `plugin` folder to `/path/to/your/vault/.obsidian/plugins/obsidian-nodejs-sync-plugin on macos at the root of this monorepo: `ln -s ./plugin /path/to/your/vault/.obsidian/plugins/obsidian-nodejs-sync-plugin` 26 | 27 | or in my case, 28 | 29 | ```bash 30 | ln -s ~/projects/obsidian-diy-sync/plugin ~/Documents/TestingVault/.obsidian/plugins/obsidian-nodejs-sync-plugin 31 | ``` 32 | 33 | On linux: 34 | 35 | ```bash 36 | ln -s ~/projects/obsidian-diy-sync/plugin/* ~/Documents/TestingVault/.obsidian/plugins/obsidian-nodejs-sync-plugin/ 37 | ``` 38 | 39 | 1. add a `.env` file to `./server` with the following variables 40 | 41 | ``` 42 | JWT_REFRESH_SECRET=some secret here! 43 | JWT_ACCESS_SECRET=some different secret here! 44 | DATABASE_URL=file:/some/path/to/sqlite.db 45 | CLIENT_SECRET=yet another secret here 46 | LOCALE=en-US (or your locale here) 47 | ``` 48 | 49 | 1. Now in the monorepo, you can start the plugin in watch mode with `pnpm dev:plugin` 50 | 1. Start the server in dev mode `pnpm dev:server` 51 | 52 | You will need to reload the plugin in Obsidian to see the changes in the plugin, but the code should be watching for changes in both the server and plugin. 53 | Now you can develop in both apps at once! 54 | 55 | ## Plugin 56 | 57 | The plugin gives Obsidian a palette command that will sync your vault to the supplied endpoint. You will need to deploy the server somewhere (see the Deploy to Render button), or you can run it locally. 58 | 59 | The plugin sends a POST request to the supplied apiHost+apiEndpoint containing your whole vault. 60 | The plugin also has can GET a vault, so you can sync to another vault. 61 | 62 | ## Server 63 | 64 | The server is needed to talk to the database (in this case, sqlite). The server receives a PUT or GET request from the plugin and stores or retrieves the appropriate data. 65 | 66 | Media is not currently supported. 67 | 68 | ### Dockerfile 69 | 70 | To build the server into a Docker image (use docker if you prefer): 71 | 72 | ```sh 73 | podman build -f Dockerfile -t obsidian-server 74 | ``` 75 | 76 | and to run it, use the `.env` file created earlier. Note: you may want to mount the sqlite db file from a volume 77 | 78 | ```sh 79 | podman run -p 8000:8000 -d --name obsidian-server --env-file=.env obsidian-server:latest 80 | ``` 81 | 82 | #### docker-compose 83 | 84 | The docker-compose.yaml file is intended for use when developing. You can easily modify it for production if you'd like 85 | 86 | ### The Blog Route 87 | 88 | There is also a route at `/api/blog` that is not blocked by cors by default. Given a query string parameter of a vault name, you can fetch all nodes that have frontmatter `published: true` or a #published hashtag. (The #published hashtag is removed from the response.) 89 | 90 | I have made the server to be deployable to [render.com](https://render.com) which is able to mount a file for use as the sqlite database. 91 | 92 | Click the button below to get started!\* 93 | 94 | [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy) 95 | 96 | ## Auth 97 | 98 | Auth is achieved through username and password exchanged for tokens. There is no password length or complexity requirement. 99 | 100 | A user can only be created from a client if the client secret is present on both client and server. This way, not just anybody who knows your server's endpoint can create a new user. 101 | 102 | Please use a secure password. Also do not share your backend URL, unless you want to share a backend; multiple users and vaults are supported, however at this time you cannot share vaults between users (Open to supporting this). 103 | 104 | A successful creation of a user or signup will return a refresh token and access token. Both tokens are set to httpOnly cookies for 7 days and 15 minutes respectively. Once the access token expires, if the refresh token has not expired and it matches the refresh token in the DB, it will be exchanged for a new refresh and access token. 105 | 106 | Currently on the plugin side, the username is being stored in localstorage which is sent with the POST request on the refresh_token route. I'm not sure if this is the best approach and I'm open to suggestions for auth in general. Is this the best approach? 107 | 108 | I would like to implement a OTP feature which takes in an email or phone number and sends a password. This password would be exchanged for the refresh and access tokens. I think this would be nice because the DB wouldn't have to hold a username and password necessarily 109 | 110 | ## Future Development 111 | 112 | - Sync on save 113 | - Add routes to the server to be able to grab single nodes, or nodes by tag 114 | - "magic link" or OTP login 115 | - Media storage 116 | -------------------------------------------------------------------------------- /configs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "forceConsistentCasingInFileNames": true, 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "noImplicitAny": true, 10 | "alwaysStrict": true, 11 | "noUnusedLocals": true, 12 | "skipLibCheck": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /configs/vitest-environment-obsync/.gitignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /configs/vitest-environment-obsync/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vitest-environment-obsync", 3 | "version": "0.1.0", 4 | "description": "Setup environment for sqlite", 5 | "exports": "./dist/vitest-environment-obsync.js", 6 | "type": "module", 7 | "scripts": { 8 | "build": "rm -rf ./dist && tsc" 9 | }, 10 | "private": true, 11 | "keywords": [], 12 | "author": "CobyPear", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/uuid": "^10.0.0", 16 | "@typescript-eslint/eslint-plugin": "^8.21.0", 17 | "@typescript-eslint/parser": "^8.21.0", 18 | "eslint": "^9.18.0", 19 | "prettier": "^3.4.2", 20 | "vitest": "^3.0.3" 21 | }, 22 | "dependencies": { 23 | "uuid": "^11.0.5" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /configs/vitest-environment-obsync/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /configs/vitest-environment-obsync/vitest-environment-obsync.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import { v4 as uuid } from 'uuid'; 4 | import type { Environment } from 'vitest/environments'; 5 | 6 | const __dirname = process.cwd(); 7 | 8 | export default { 9 | transformMode: 'ssr', 10 | name: 'obsync', 11 | setup(global) { 12 | const dbName = `test_db_${uuid()}.db`; 13 | const dbPath = path.join(__dirname, dbName); 14 | const DATABASE_URL = `${dbName}`; 15 | 16 | global.process.env.DATABASE_URL = DATABASE_URL; 17 | return { 18 | async teardown() { 19 | await fs.promises.unlink(dbPath); 20 | }, 21 | }; 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | test: 3 | volumes: 4 | - './configs:/app/configs/' 5 | - './server:/app/server/' 6 | env_file: 7 | - .env 8 | build: 9 | context: . 10 | target: test 11 | 12 | dev-server: 13 | container_name: obsync-server 14 | volumes: 15 | - './server:/app/server/' 16 | ports: 17 | - 8000:8080 18 | env_file: 19 | - .env 20 | build: 21 | context: . 22 | target: dev 23 | -------------------------------------------------------------------------------- /docs/motivation.md: -------------------------------------------------------------------------------- 1 | > Why did you build this? 2 | 3 | I am still a relatively new developer and I wanted to cut my teeth on a backend focused project. I also love using Obsidian.md for my notes and while there are other (likely better) sync options out there, I wanted to create my own 'publish and sync' solution. I ended up with this Obsidian plugin + express server. 4 | 5 | > What does it do? 6 | 7 | There are two pieces of this project. 8 | 9 | 1. A node.js server 10 | 2. An Obsidian.md plugin 11 | Both are written in TypeScript and the projects live inside of the same monorepo. 12 | It is possible to run everything locally-- the plugin can be symlinked to an Obsidian vault and installed as a community plugin. From there you set your endpoint. Then, you may create a user and sync your vault to the server. 13 | 14 | The server can have any number of users, and each user can have any number of vaults. Upper limits are likely bound to SQLite, or Node/express constraints. For example, a vault with a lot of nodes will be too large to sync with the server. (I am looking into ways to make it possible to sync a vault of any size and I am open to suggestions!) 15 | 16 | ### The Blog Route 17 | 18 | The sugar on top is the `/api/blog` route that comes with the server. Any post with frontmatter `published: true` or a hashtag #published will automatically be served at this route. That way you can use Obsidian as sort of a rudimentary CMS or at least blog editing station. Ready to publish? Add the tag and sync your vault. If you use SSR on your frontend to consume this API route your blog will be updated as fast as the bytes can cross the wire. 19 | 20 | > Why should I care? 21 | 22 | I'm not sure if you should! If you want to create a [digital garden](https://tomcritchlow.com/2019/02/17/building-digital-garden/) of your own, I think this is a nice way to handle some of the backend. That way you can focus on the content and the frontend goodness instead of fighting with servers all day. 23 | -------------------------------------------------------------------------------- /docs/setting-up-the-server.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | The server can be built as a plain node.js app and served as you see fit, 4 | or you can use the Dockerfile 5 | 6 | ## Server Architecture 7 | 8 | The server is an express.js server written in TypeScript. It uses SQLite for the database. 9 | 10 | ## Hosting The Server 11 | 12 | - The server host will need nodejs version 18 LTS or greater. 13 | - The server will also need a persistent disk for the SQLite database. 14 | -------------------------------------------------------------------------------- /docs/using-the-plugin.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | There are two parts to the plugin: 4 | 5 | 1. An Obsidian Plugin that you install into Obsidian itself 6 | 2. An Express.js server + SQLite database that you host somewhere 7 | 8 | For local development, see the README.md at the root of the GitHub repo. 9 | 10 | ## Installing The Plugin 11 | 12 | The plugin can be installed through the Community Plugins tab through the 13 | Obsidian Settings. 14 | 15 | ## Hosting The Server 16 | 17 | The server is a node.js application which can be hosted anywhere node can run. 18 | 19 | You may run the server locally until you feel comfortable hosting. 20 | 21 | ### Server Technologies 22 | 23 | - TypeScript 24 | - Express.js 25 | - SQLite 26 | 27 | ## Using The Plugin 28 | 29 | All actions in the plugin are done through the command pallette (ctrl + P or cmd + P by default). 30 | 31 | Before using any commands, navigate to **Settings** > **Nodejs Sync Plugin** and configure the hostname and endpoint (use the default endpoint unless it has been changed in the server). 32 | 33 | The `Vault to Fetch` is the name of a vault that has previously been sync'd. This will usually be the name of the vault you are working in, but if you are signing in from a new machine you may want to use this to grab notes from a previous sync. 34 | 35 | In order to prevent anyone with your server's name to create new users and start syncing vaults, there is a client secret that must be known to both client and server. Without this secret, you can not create a new user. 36 | 37 | After configuring the server hostname and client secret, create a new user: 38 | 39 | 1. In the command pallette search for the **Nodejs Sync Plugin: Create New User** command 40 | 2. Fill out the form with a secure username and password. The password is 41 | encrypted in the database. There is currently no password recovery or way to 42 | change it. Please use a password manager :smile: 43 | 44 | Now you will should able to use the rest of the commands from the command 45 | palette. 46 | 47 | ### Sync a Vault 48 | 49 | To sync your vault with the server, make sure you are logged in as the correct user and use the **Sync Vault** command. If the vault doesn't exist yet it will be created. If it does exist it will be updated with the latest changes. There is no smart diffing at the moment. 50 | 51 | ### Get a Vault 52 | 53 | The vault that will be fetched needs to be set in the plugin's settings. If this is not set, the server will not know which vault to send back. This is because you can **Get** a vault on an empty vault in order to sync a new machine with the server. The vault name is the same as the name of the vault when you first Sync it. 54 | 55 | Currently, if you delete a file and Get the vault, the file will be restored. Getting a vault should not override your current changes if they are different from your sync'd version. I am open to developing some logic around this. 56 | 57 | ### Delete a User 58 | 59 | Only the currently logged in user can be deleted. **BE CAREFUL!!** If you delete the current user, all data in the server belonging to that user (vaults, nodes) will be deleted as well. 60 | Make sure you really want to delete that data from the DB before proceeding! 61 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 2 | import globals from 'globals'; 3 | import tsParser from '@typescript-eslint/parser'; 4 | import path from 'node:path'; 5 | import { fileURLToPath } from 'node:url'; 6 | import js from '@eslint/js'; 7 | import { FlatCompat } from '@eslint/eslintrc'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | recommendedConfig: js.configs.recommended, 14 | allConfig: js.configs.all, 15 | }); 16 | 17 | export default [ 18 | ...compat.extends( 19 | 'eslint:recommended', 20 | 'plugin:@typescript-eslint/eslint-recommended', 21 | 'plugin:@typescript-eslint/recommended', 22 | ), 23 | { 24 | ignores: ['**/build/', '**/dist/', 'plugin/main.js'], 25 | }, 26 | { 27 | plugins: { 28 | '@typescript-eslint': typescriptEslint, 29 | }, 30 | 31 | languageOptions: { 32 | globals: { 33 | ...globals.node, 34 | }, 35 | 36 | parser: tsParser, 37 | ecmaVersion: 5, 38 | sourceType: 'module', 39 | }, 40 | 41 | rules: { 42 | semi: 'error', 43 | 'no-unused-vars': 'off', 44 | 45 | '@typescript-eslint/no-unused-vars': [ 46 | 'error', 47 | { 48 | args: 'none', 49 | }, 50 | ], 51 | 52 | '@typescript-eslint/ban-ts-comment': 'off', 53 | 'no-prototype-builtins': 'off', 54 | '@typescript-eslint/no-empty-function': 'off', 55 | }, 56 | }, 57 | ]; 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-diy-sync", 3 | "version": "0.2.0", 4 | "description": "Obsidian plugin that syncs data to an sqlite backed express.js server", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "format": "prettier . --write", 8 | "dev:server": "pnpm -F './server' dev", 9 | "dev:plugin": "pnpm -F './plugin' dev", 10 | "build:configs": "pnpm -F './configs/**' build", 11 | "build:server": "pnpm -F './server' build:prod", 12 | "start:server": "pnpm -F './server' start:prod", 13 | "test:server": "pnpm build:configs && pnpm -F './server' test", 14 | "server:test:watch": "pnpm -F './server' test:watch" 15 | }, 16 | "keywords": [ 17 | "obsidian.md", 18 | "obsidian-sync", 19 | "obsidian-plugin", 20 | "sqlite" 21 | ], 22 | "author": "CobyPear", 23 | "license": "MIT", 24 | "devDependencies": { 25 | "@eslint/eslintrc": "^3.2.0", 26 | "@eslint/js": "^9.18.0", 27 | "@types/node": "^22.10.8", 28 | "@typescript-eslint/eslint-plugin": "^8.21.0", 29 | "@typescript-eslint/parser": "^8.21.0", 30 | "eslint": "^9.18.0", 31 | "globals": "^15.14.0", 32 | "prettier": "^3.4.2", 33 | "typescript": "^5.7.3" 34 | }, 35 | "pnpm": { 36 | "overrides": { 37 | "vitest-environment-obsync": "workspace:*" 38 | }, 39 | "onlyBuiltDependencies": [ 40 | "bcrypt", 41 | "nodemon", 42 | "sqlite3", 43 | "better-sqlite3", 44 | "esbuild" 45 | ] 46 | }, 47 | "engines": { 48 | "node": ">=18 <=22" 49 | }, 50 | "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" 51 | } 52 | -------------------------------------------------------------------------------- /plugin/.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /plugin/.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled files in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | *.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store -------------------------------------------------------------------------------- /plugin/.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /plugin/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Coby Sher 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 | 23 | -------------------------------------------------------------------------------- /plugin/README.md: -------------------------------------------------------------------------------- 1 | ## Obsidian DIY Sync Plugin 2 | 3 | An Obsidian.md plugin that interacts with a self-hostable nodejs server 4 | 5 | ## Plugin 6 | 7 | The plugin gives Obsidian a palette command that will sync your vault to the supplied endpoint. You will need to deploy the server somewhere or you can run it locally. 8 | 9 | The plugin sends a POST request to the supplied apiHost+apiEndpoint containing your whole vault. 10 | The plugin also has can GET a vault, so you can sync to another 11 | 12 | ### Settings 13 | 14 | - `apiHost` - the location of your server. `http://localhost:3001` by default 15 | - `endpoint` - The endpoint to send and get the vault. `/api/vault` by default 16 | - vaultToFetch - The name of the vault to sync and fetch. This is usually the name of your current vault, unless you are fetching one that exists already on the server. 17 | 18 | ### Commands 19 | 20 | #### Create User 21 | 22 | Provide a username and password. Multiple users are possible per server. Users can only fetch their own vaults. Users can also sync multiple vaults. 23 | 24 | #### Login 25 | 26 | Login to the server with a created user and password. You will need to login when the refresh token expires or the cookie in the app is cleared. 27 | 28 | #### Logout 29 | 30 | Logout the currently logged in user. 31 | 32 | #### Sync Vault 33 | 34 | Send the contents of the current vault to the server. The name of the vault on the server will be the `vaultToFetch` 35 | 36 | #### Get Vault 37 | 38 | Get the contents of the current vault 39 | 40 | ## Server 41 | 42 | See [the monorepo](https://github.com/cobypear/obsidian-diy-sync) for more info and docs. 43 | 44 | Features: 45 | 46 | - Authentication 47 | - Multiple users per server 48 | - Multiple vaults per user 49 | - Vault fetchable only by the user who created it 50 | 51 | ## Troubleshooting 52 | 53 | Open the dev console with `ctrl+shift+i` to see any errors from the plugin or the server 54 | -------------------------------------------------------------------------------- /plugin/commands/deleteUser.ts: -------------------------------------------------------------------------------- 1 | import { App } from 'obsidian'; 2 | import { MessageModal } from 'components/modals'; 3 | import { NodeSyncPluginSettings } from 'types'; 4 | 5 | export const deleteUser = async ( 6 | settings: NodeSyncPluginSettings, 7 | app: App, 8 | ) => { 9 | const username = localStorage.getItem('user'); 10 | 11 | if (username) { 12 | const res = await fetch(`${settings.apiHost}/api/user`, { 13 | method: 'DELETE', 14 | headers: { 15 | 'Content-Type': 'application/json', 16 | }, 17 | credentials: 'include', 18 | body: JSON.stringify({ username }), 19 | }); 20 | if (res.ok) { 21 | const data = await res.json(); 22 | localStorage.removeItem('user'); 23 | new MessageModal(app, data.message).open(); 24 | } 25 | } else { 26 | new MessageModal(app, 'No user to delete').open(); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /plugin/components/modals.ts: -------------------------------------------------------------------------------- 1 | import NodeSyncPlugin from 'main'; 2 | import { Modal, App, Setting } from 'obsidian'; 3 | 4 | type modalType = 'user' | 'login'; 5 | 6 | export class LoginModal extends Modal { 7 | username: string; 8 | password: string; 9 | confirmPass: string; 10 | url: string; 11 | plugin: NodeSyncPlugin; 12 | // is this a login modal 13 | // or a create user modal? 14 | modalType: modalType; 15 | clientSecret: string; 16 | isWarningShown = false; 17 | 18 | constructor( 19 | app: App, 20 | plugin: NodeSyncPlugin, 21 | url: string, 22 | modalType: modalType, 23 | clientSecret: string, 24 | ) { 25 | super(app); 26 | this.plugin = plugin; 27 | this.url = url; 28 | this.modalType = modalType; 29 | this.clientSecret = clientSecret; 30 | } 31 | 32 | async onSubmit(username: string, password: string) { 33 | try { 34 | const secret = 35 | this.modalType === 'user' ? { secret: this.clientSecret } : {}; 36 | // POST to /api/login 37 | // if 200 response, the token should be accessible in a cookie? 38 | // otherwise, open a new modal with the error 39 | const res = await fetch(`${this.url}/api/${this.modalType}`, { 40 | method: 'POST', 41 | credentials: 'include', 42 | headers: { 43 | 'Content-Type': 'application/json', 44 | }, 45 | body: JSON.stringify({ username, password, ...secret }), 46 | }); 47 | const data = await res.json(); 48 | if (!data.username) { 49 | return new MessageModal(this.app, data.message).open(); 50 | } else { 51 | this.setCurrentUser(data.username); 52 | return new MessageModal(this.app, data.message).open(); 53 | } 54 | } catch (error) { 55 | console.error(error); 56 | } 57 | } 58 | 59 | setCurrentUser(username: string) { 60 | localStorage.setItem('user', username); 61 | } 62 | 63 | onOpen() { 64 | const title = 65 | this.modalType === 'user' ? 'Create a User' : `Login to ${this.url}`; 66 | const { contentEl } = this; 67 | contentEl.addClass('login-modal'); 68 | contentEl.createEl('h1', { text: title }); 69 | // Username input control 70 | new Setting(contentEl).setName('Username').addText((text) => 71 | text.onChange((value) => { 72 | this.username = value; 73 | }), 74 | ); 75 | 76 | // Password input control 77 | new Setting(contentEl).setName('Password').addText((text) => { 78 | text.inputEl.type = this.modalType === 'login' ? 'password' : 'text'; 79 | return text.onChange((value) => { 80 | this.password = value; 81 | }); 82 | }); 83 | 84 | switch (this.modalType) { 85 | case 'user': { 86 | new Setting(contentEl).setName('Confirm Password').addText((text) => { 87 | return text.onChange((value) => { 88 | this.confirmPass = value; 89 | }); 90 | }); 91 | 92 | new Setting(contentEl).addButton((btn) => 93 | btn 94 | .setButtonText('Create User') 95 | .setCta() 96 | .onClick(() => { 97 | // Show an error to the user that credentials are missing 98 | if (this.password !== this.confirmPass) { 99 | if (!this.isWarningShown) { 100 | const warning = contentEl.createEl('span', { 101 | text: 'Passwords do not match. Please confirm passwords match and try again.', 102 | cls: ['warning', 'fade-out'], 103 | }); 104 | this.isWarningShown = true; 105 | 106 | setTimeout(() => { 107 | this.contentEl.removeChild(warning); 108 | this.isWarningShown = false; 109 | }, 5000); 110 | } 111 | } else { 112 | this.onSubmit(this.username, this.password); 113 | this.close(); 114 | } 115 | }), 116 | ); 117 | break; 118 | } 119 | case 'login': 120 | { 121 | new Setting(contentEl).addButton((btn) => 122 | btn 123 | .setButtonText('Login') 124 | .setCta() 125 | .onClick(() => { 126 | // Show an error to the user that credentials are missing 127 | if (!this.password || !this.username) { 128 | if (!this.isWarningShown) { 129 | const warning = contentEl.createEl('span', { 130 | text: 'Missing credentials. Please input username and password.', 131 | cls: ['warning', 'fade-in', 'fade-out'], 132 | }); 133 | this.isWarningShown = true; 134 | 135 | setTimeout(() => { 136 | this.contentEl.removeChild(warning); 137 | this.isWarningShown = false; 138 | }, 5000); 139 | } 140 | } else { 141 | this.close(); 142 | this.onSubmit(this.username, this.password); 143 | } 144 | }), 145 | ); 146 | } 147 | break; 148 | } 149 | } 150 | // Login button 151 | onClose() { 152 | const { contentEl } = this; 153 | contentEl.empty(); 154 | } 155 | } 156 | 157 | export class MessageModal extends Modal { 158 | message: string; 159 | constructor(app: App, message: string) { 160 | super(app); 161 | this.message = message; 162 | } 163 | 164 | onOpen() { 165 | const { contentEl } = this; 166 | contentEl.setText(this.message); 167 | } 168 | 169 | onClose() { 170 | const { contentEl } = this; 171 | contentEl.empty(); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /plugin/esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import process from 'process'; 3 | import builtins from 'builtin-modules'; 4 | 5 | const banner = `/* 6 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 7 | if you want to view the source, please visit the github repository of this plugin 8 | */ 9 | `; 10 | 11 | const prod = process.argv[2] === 'production'; 12 | 13 | const ctx = await esbuild.context({ 14 | banner: { 15 | js: banner, 16 | }, 17 | entryPoints: ['main.ts'], 18 | bundle: true, 19 | external: [ 20 | 'obsidian', 21 | 'electron', 22 | '@codemirror/autocomplete', 23 | '@codemirror/closebrackets', 24 | '@codemirror/collab', 25 | '@codemirror/commands', 26 | '@codemirror/comment', 27 | '@codemirror/fold', 28 | '@codemirror/gutter', 29 | '@codemirror/highlight', 30 | '@codemirror/history', 31 | '@codemirror/language', 32 | '@codemirror/lint', 33 | '@codemirror/matchbrackets', 34 | '@codemirror/panel', 35 | '@codemirror/rangeset', 36 | '@codemirror/rectangular-selection', 37 | '@codemirror/search', 38 | '@codemirror/state', 39 | '@codemirror/stream-parser', 40 | '@codemirror/text', 41 | '@codemirror/tooltip', 42 | '@codemirror/view', 43 | '@lezer/common', 44 | '@lezer/highlight', 45 | '@lezer/lr', 46 | ...builtins, 47 | ], 48 | format: 'cjs', 49 | target: 'es2016', 50 | logLevel: 'info', 51 | sourcemap: prod ? false : 'inline', 52 | treeShaking: true, 53 | outfile: 'main.js', 54 | }); 55 | 56 | await ctx.watch(); 57 | -------------------------------------------------------------------------------- /plugin/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 2 | import globals from 'globals'; 3 | import tsParser from '@typescript-eslint/parser'; 4 | import path from 'node:path'; 5 | import { fileURLToPath } from 'node:url'; 6 | import js from '@eslint/js'; 7 | import { FlatCompat } from '@eslint/eslintrc'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | recommendedConfig: js.configs.recommended, 14 | allConfig: js.configs.all, 15 | }); 16 | 17 | export default [ 18 | ...compat.extends( 19 | 'eslint:recommended', 20 | 'plugin:@typescript-eslint/eslint-recommended', 21 | 'plugin:@typescript-eslint/recommended', 22 | ), 23 | { 24 | plugins: { 25 | '@typescript-eslint': typescriptEslint, 26 | }, 27 | 28 | languageOptions: { 29 | globals: { 30 | ...globals.node, 31 | }, 32 | 33 | parser: tsParser, 34 | ecmaVersion: 5, 35 | sourceType: 'module', 36 | }, 37 | 38 | rules: { 39 | 'no-unused-vars': 'off', 40 | 41 | '@typescript-eslint/no-unused-vars': [ 42 | 'error', 43 | { 44 | args: 'none', 45 | }, 46 | ], 47 | 48 | '@typescript-eslint/ban-ts-comment': 'off', 49 | 'no-prototype-builtins': 'off', 50 | '@typescript-eslint/no-empty-function': 'off', 51 | }, 52 | }, 53 | ]; 54 | -------------------------------------------------------------------------------- /plugin/main.ts: -------------------------------------------------------------------------------- 1 | import { App, Plugin, PluginSettingTab, Setting } from 'obsidian'; 2 | import { NodeSyncPluginSettings, VaultToSync, Node } from 'types'; 3 | import { extractContent } from './utils/extractContent'; 4 | import { writeNodeToFile } from 'utils/writeNodeToFile'; 5 | import { LoginModal, MessageModal } from 'components/modals'; 6 | import { refreshToken } from 'utils/refreshToken'; 7 | import { deleteUser } from 'commands/deleteUser'; 8 | 9 | const DEFAULT_SETTINGS: NodeSyncPluginSettings = { 10 | apiHost: 'http://localhost:3001', 11 | endpoint: '/api/vault', 12 | vaultToFetch: 'default', 13 | clientSecret: 'keyboard_cat!!', 14 | }; 15 | 16 | export default class NodeSyncPlugin extends Plugin { 17 | settings: NodeSyncPluginSettings; 18 | url: string; 19 | 20 | async onload() { 21 | await this.loadSettings(); 22 | this.url = `${this.settings.apiHost}${this.settings.endpoint}`; 23 | 24 | // add command that syncs the vault to the api host in settings 25 | this.addCommand({ 26 | id: 'sync-vault-to-server', 27 | name: 'Sync Vault', 28 | callback: async () => { 29 | const files = this.app.vault.getMarkdownFiles(); 30 | 31 | // extracts all content from the list of files 32 | // need to make a nice object to send to the BE 33 | // it should have the file metadata so that a vault that is fetched can be rebuilt 34 | 35 | const filesToPut: VaultToSync = { 36 | vault: '', 37 | nodes: [], 38 | }; 39 | for (const file of files) { 40 | try { 41 | const content = await extractContent(file); 42 | if (!content) { 43 | throw new Error(`Could not extract content from ${file.name}`); 44 | } 45 | const node = { 46 | content: content as string, 47 | name: file.name, 48 | extension: file.extension, 49 | path: file.path, 50 | ctime: file.stat.ctime.toString(), 51 | mtime: file.stat.mtime.toString(), 52 | }; 53 | filesToPut.nodes.push(node); 54 | } catch (error) { 55 | console.error(error); 56 | } 57 | } 58 | // add the name so we know which vault to update on the server 59 | filesToPut.vault = this.app.vault.getName(); 60 | 61 | try { 62 | let res = await fetch(this.url, { 63 | method: 'PUT', 64 | credentials: 'include', 65 | headers: { 66 | 'Content-Type': 'application/json', 67 | }, 68 | body: JSON.stringify(filesToPut), 69 | }); 70 | const user = localStorage.getItem('user'); 71 | if (!res.ok && user) { 72 | const refreshSuccess = await refreshToken( 73 | this.settings.apiHost, 74 | user, 75 | ); 76 | if (refreshSuccess) { 77 | res = await fetch(this.url, { 78 | method: 'PUT', 79 | credentials: 'include', 80 | headers: { 81 | 'Content-Type': 'application/json', 82 | }, 83 | body: JSON.stringify(filesToPut), 84 | }); 85 | } 86 | } 87 | 88 | if (res.ok) { 89 | new MessageModal( 90 | this.app, 91 | `Successfully sync'd ${this.app.vault.getName()} to ${ 92 | this.settings.apiHost 93 | }!`, 94 | ).open(); 95 | } else { 96 | return new MessageModal( 97 | this.app, 98 | 'Session expired.\nPlease log in to the server.', 99 | ).open(); 100 | } 101 | } catch (error) { 102 | console.error(error); 103 | } 104 | }, 105 | }); 106 | 107 | // get a remote vault 108 | this.addCommand({ 109 | id: 'get-vault-from-server', 110 | name: 'Get Vault', 111 | callback: async () => { 112 | if (!this.url || !this.settings.vaultToFetch) { 113 | throw new Error( 114 | 'URL and vaultToFetch required. See settings for more details.', 115 | ); 116 | } 117 | try { 118 | console.log(`Fetching ${this.settings.vaultToFetch}...`); 119 | let res = await fetch( 120 | `${this.url}?vault=${this.settings.vaultToFetch}`, 121 | { 122 | credentials: 'include', 123 | }, 124 | ); 125 | const user = localStorage.getItem('user'); 126 | if (!res.ok && user && res.status === 401) { 127 | const refreshSuccess = await refreshToken( 128 | this.settings.apiHost, 129 | user, 130 | ); 131 | console.log('refreshSuccess', refreshSuccess); 132 | if (refreshSuccess) { 133 | // try to fetch the vault again if the refresh token was successful 134 | res = await fetch( 135 | `${this.url}?vault=${this.settings.vaultToFetch}`, 136 | { 137 | credentials: 'include', 138 | }, 139 | ); 140 | } 141 | } 142 | if (res.ok) { 143 | const { name: vaultName, nodes } = await res.json(); 144 | try { 145 | await Promise.all( 146 | nodes.map(async (node: Node) => { 147 | // write the node back into the file 148 | await writeNodeToFile(node, vaultName, this.app.vault); 149 | }), 150 | ); 151 | new MessageModal( 152 | this.app, 153 | `Successfully retrieved ${this.settings.vaultToFetch}`, 154 | ).open(); 155 | } catch (error) { 156 | console.error(error); 157 | } 158 | } else { 159 | const message = 160 | res.status === 404 161 | ? 'Vault not found for this user\n Please log in with the correct user' 162 | : 'Session expired\n Please log in to the server.'; 163 | return new MessageModal(this.app, message).open(); 164 | } 165 | } catch (error) { 166 | console.error(error); 167 | return new MessageModal( 168 | this.app, 169 | 'An error occurred. Check the console (ctrl+shift+i)', 170 | ).open(); 171 | } 172 | }, 173 | }); 174 | 175 | // LOGIN USER COMMAND 176 | this.addCommand({ 177 | id: 'open-login-modal', 178 | name: 'Login to Server', 179 | callback: async () => { 180 | const loginModal = new LoginModal( 181 | this.app, 182 | this, 183 | this.settings.apiHost, 184 | 'login', 185 | this.settings.clientSecret, 186 | ); 187 | return loginModal.open(); 188 | }, 189 | }); 190 | 191 | // CREATE USER COMMAND 192 | this.addCommand({ 193 | id: 'open-create-user-modal', 194 | name: 'Create New User', 195 | callback: async () => { 196 | const loginModal = new LoginModal( 197 | this.app, 198 | this, 199 | this.settings.apiHost, 200 | 'user', 201 | this.settings.clientSecret, 202 | ); 203 | return loginModal.open(); 204 | }, 205 | }); 206 | 207 | // LOGOUT USER COMMAND 208 | this.addCommand({ 209 | id: 'logout-user', 210 | name: 'Logout User', 211 | callback: async () => { 212 | const username = localStorage.getItem('user'); 213 | 214 | if (username) { 215 | const res = await fetch(`${this.settings.apiHost}/api/logout`, { 216 | method: 'POST', 217 | headers: { 218 | 'Content-Type': 'application/json', 219 | }, 220 | credentials: 'include', 221 | body: JSON.stringify({ username }), 222 | }); 223 | if (res.ok) { 224 | const data = await res.json(); 225 | localStorage.removeItem('user'); 226 | new MessageModal(this.app, data.message).open(); 227 | } 228 | } else { 229 | new MessageModal(this.app, 'No user to logout').open(); 230 | } 231 | }, 232 | }); 233 | 234 | // DELETE USER COMMAND 235 | this.addCommand({ 236 | id: 'delete-user', 237 | name: 'Delete current User', 238 | callback: () => deleteUser(this.settings, this.app), 239 | }); 240 | 241 | // This adds a settings tab so the user can configure various aspects of the plugin 242 | this.addSettingTab(new NodeSyncSettingTab(this.app, this)); 243 | } 244 | 245 | onunload() {} 246 | 247 | async loadSettings() { 248 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 249 | } 250 | 251 | async saveSettings() { 252 | await this.saveData(this.settings); 253 | } 254 | } 255 | 256 | class NodeSyncSettingTab extends PluginSettingTab { 257 | plugin: NodeSyncPlugin; 258 | 259 | constructor(app: App, plugin: NodeSyncPlugin) { 260 | super(app, plugin); 261 | this.plugin = plugin; 262 | } 263 | 264 | display(): void { 265 | const { containerEl } = this; 266 | 267 | containerEl.empty(); 268 | 269 | containerEl.createEl('h2', { 270 | text: 'Settings for my Nodejs Sync plugin.', 271 | }); 272 | 273 | // API Host setting 274 | new Setting(containerEl) 275 | .setName('API Host') 276 | .setDesc('The location of your server') 277 | .addText((text) => 278 | text 279 | .setPlaceholder( 280 | 'Enter your api host. For example https://my.hostingprovider.myapp.com', 281 | ) 282 | .setValue(this.plugin.settings.apiHost) 283 | .onChange(async (value) => { 284 | this.plugin.settings.apiHost = value; 285 | await this.plugin.saveSettings(); 286 | }), 287 | ); 288 | // API Endpoint setting 289 | new Setting(containerEl) 290 | .setName('API Endpoint') 291 | .setDesc('The location to send your vault') 292 | .addText((text) => 293 | text 294 | .setPlaceholder('Enter your api endpoint. For example /api/vault') 295 | .setValue(this.plugin.settings.endpoint) 296 | .onChange(async (value) => { 297 | this.plugin.settings.endpoint = value; 298 | await this.plugin.saveSettings(); 299 | }), 300 | ); 301 | // Vault to Fetch setting 302 | new Setting(containerEl) 303 | .setName('Vault to fetch') 304 | .setDesc( 305 | 'The name of the vault to fetch when using the Get Vault command', 306 | ) 307 | .addText((text) => 308 | text 309 | .setPlaceholder('Enter the name of your remote vault') 310 | .setValue(this.plugin.settings.vaultToFetch as string) 311 | .onChange(async (value) => { 312 | this.plugin.settings.vaultToFetch = value; 313 | await this.plugin.saveSettings(); 314 | }), 315 | ); 316 | // client secret setting 317 | new Setting(containerEl) 318 | .setName('Client Secret') 319 | .setDesc( 320 | 'A string shared between the client and server. Keep this secret and only share with valid users of your server.', 321 | ) 322 | .addText((text) => { 323 | text.inputEl.type = 'password'; 324 | return text 325 | .setPlaceholder('Enter the client secret') 326 | .setValue(this.plugin.settings.vaultToFetch as string) 327 | .onChange(async (value) => { 328 | this.plugin.settings.clientSecret = value; 329 | await this.plugin.saveSettings(); 330 | }); 331 | }); 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /plugin/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-nodejs-sync", 3 | "name": "Nodejs Sync Plugin", 4 | "version": "0.1.0", 5 | "minAppVersion": "0.1.0", 6 | "description": "This plugin allows you to sync your notes to a nodejs server with express and sqlite.", 7 | "author": "CobyPear", 8 | "authorUrl": "https://cobysher.dev", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-nodejs-sync", 3 | "version": "0.1.0", 4 | "description": "This is a plugin for Obsidian (https://obsidian.md) that allows you to sync your Vault to a nodejs server running express + sqlite.", 5 | "main": "main.js", 6 | "scripts": { 7 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 8 | "dev": "node esbuild.config.mjs", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@eslint/eslintrc": "^3.2.0", 16 | "@eslint/js": "^9.18.0", 17 | "@types/node": "^22.10.8", 18 | "@typescript-eslint/eslint-plugin": "8.21.0", 19 | "@typescript-eslint/parser": "8.21.0", 20 | "builtin-modules": "4.0.0", 21 | "esbuild": "0.24.2", 22 | "globals": "^15.14.0", 23 | "obsidian": "latest", 24 | "tslib": "2.8.1", 25 | "typescript": "5.7.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /plugin/styles.css: -------------------------------------------------------------------------------- 1 | .warning { 2 | display: flex; 3 | margin: 0 4px; 4 | padding: 12px; 5 | background-color: #d1c1c1; 6 | color: #ff0000; 7 | } 8 | .warning::before { 9 | content: '\26A0\0020'; 10 | } 11 | .fade-in { 12 | animation: fade-in 3s; 13 | } 14 | .fade-out { 15 | animation: fade-out 3s; 16 | animation-delay: 3s; 17 | } 18 | 19 | @keyframes fade-in { 20 | 0% { 21 | opacity: 0; 22 | } 23 | 100% { 24 | opacity: 1; 25 | } 26 | } 27 | @keyframes fade-out { 28 | 0% { 29 | opacity: 1; 30 | } 31 | 100% { 32 | opacity: 0; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "allowSyntheticDefaultImports": true, 15 | "lib": ["DOM", "ES5", "ES6", "ES7"] 16 | }, 17 | "include": ["**/*.ts", "utils/*.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /plugin/types.ts: -------------------------------------------------------------------------------- 1 | export interface NodeSyncPluginSettings { 2 | apiHost: string; 3 | endpoint: string; 4 | clientSecret: string; 5 | vaultToFetch?: string; 6 | } 7 | 8 | export interface Node { 9 | content: string; 10 | name: string; 11 | extension: string; 12 | path: string; 13 | ctime: string; 14 | mtime: string; 15 | } 16 | 17 | export interface VaultToSync { 18 | vault: string; 19 | nodes: Node[]; 20 | } 21 | -------------------------------------------------------------------------------- /plugin/utils/extractContent.ts: -------------------------------------------------------------------------------- 1 | import type { TFile } from 'obsidian'; 2 | 3 | export const extractContent = async (file: TFile) => { 4 | try { 5 | const data = await file.vault.read(file); 6 | return data; 7 | } catch (err) { 8 | return new Error(`Cannot read file ${file.path} - ${err}`); 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /plugin/utils/refreshToken.ts: -------------------------------------------------------------------------------- 1 | export const refreshToken = async (url: string, username: string) => { 2 | const res = await fetch(`${url}/api/refresh_token`, { 3 | method: 'POST', 4 | headers: { 5 | 'Content-Type': 'application/json', 6 | }, 7 | credentials: 'include', 8 | body: JSON.stringify({ 9 | username, 10 | }), 11 | }); 12 | if (res.ok) { 13 | const data = await res.json(); 14 | console.log('data', data); 15 | return true; 16 | } else if (res.status === 401) { 17 | return false; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /plugin/utils/writeNodeToFile.ts: -------------------------------------------------------------------------------- 1 | import type { Vault } from 'obsidian'; 2 | import { Node } from '../types'; 3 | import path from 'path'; 4 | export const writeNodeToFile = async ( 5 | node: Node, 6 | vaultName: string, 7 | vault: Vault, 8 | ) => { 9 | const currentVault = vault.getName(); 10 | 11 | if (vaultName !== currentVault) { 12 | // We don't want to create a new vault for the user, instead make sure they start in the 13 | // same dir as the vault they are trying to fetch 14 | throw new Error( 15 | `${vaultName} does not match the current vault, ${currentVault}\nIf you'd like to use ${vaultName}, switch to that vault and try again.`, 16 | ); 17 | } 18 | 19 | const vaultPath = path.resolve(vault.getRoot().path); 20 | const nodePath = path.resolve(vaultPath, node.path); 21 | // If the file doesn't already exist, write it! 22 | const fileExists = await vault.adapter.exists(nodePath); 23 | 24 | if (!fileExists) { 25 | const dirs = nodePath 26 | .split('/') 27 | .filter((dir) => !dir.endsWith('.md') && dir !== ''); 28 | 29 | if (dirs.length > 0) { 30 | await vault.adapter.mkdir(path.join(vaultPath, ...dirs)); 31 | } 32 | try { 33 | const file = await vault.create(path.join(vaultPath, node.path), '', { 34 | ctime: Number(node.ctime), 35 | mtime: Number(node.mtime), 36 | }); 37 | console.log(`Created file ${node.name} at ${node.path}`); 38 | if (file) { 39 | // break the content up and put it back in line by line 40 | const multiLineContent = node.content.split('\n'); 41 | multiLineContent.forEach( 42 | async (line) => await vault.append(file, `${line}\n`), 43 | ); 44 | console.log(`Wrote file ${node.name} at ${node.path}`); 45 | } 46 | // Swallow the error? 47 | } catch (error) { 48 | console.error(error); 49 | } 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /plugin/version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync('manifest.json', 'utf8')); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t')); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync('versions.json', 'utf8')); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync('versions.json', JSON.stringify(versions, null, '\t')); 15 | -------------------------------------------------------------------------------- /plugin/versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.9.7", 3 | "1.0.1": "0.12.0" 4 | } 5 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - plugin/** 3 | - server 4 | - configs/** 5 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *debug 3 | .env 4 | build 5 | *.db 6 | coverage -------------------------------------------------------------------------------- /server/LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Coby Sher 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 | 23 | -------------------------------------------------------------------------------- /server/__tests__/mockData/nodes.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from '../../types'; 2 | 3 | export const nodes1: Partial[] = [ 4 | { 5 | content: 6 | '---\nfont: matter\nteset: value\n---\n#tag1 #tag2 #unpublished\n\n# My test note\nHere is a paragraph with some text blah blah blah.\n\nLinebreak!\n\n\n', 7 | extension: 'md', 8 | name: 'test note.md', 9 | path: 'test note.md', 10 | ctime: '1660404525820', 11 | mtime: '1660404525836', 12 | }, 13 | { 14 | content: 15 | '---\nfont: matter\nteset: value\n---\n#tag1 #tag2 #published\n\n# My test note 2\nHere is a paragraph with some text blah blah blah.\n\nLinebreak!\n\n', 16 | extension: 'md', 17 | name: 'another test note.md', 18 | path: 'notes/nested/another test note.md', 19 | ctime: '1667067654855', 20 | mtime: '1667067654875', 21 | }, 22 | { 23 | content: 24 | '---\nfont: matter\nteset: value\n---\n#tag1 #tag2 #unpublished\n\n# My test note 2\nHere is a paragraph with some text blah blah blah.\n\nLinebreak!\n', 25 | extension: 'md', 26 | name: 'another test note 1.md', 27 | path: 'notes/nested/another test note 1.md', 28 | ctime: '1660408835541', 29 | mtime: '1667067695699', 30 | }, 31 | { 32 | content: 33 | "a;ljsdf;alksdjf;alksdjf;alksdjf\n\n\n\na;osdf;aklsjdf\nalksjf;l\noihgkl;hsdfwgk czhygkfgrbsv\n'bkgj cyhsggkOUSkbFODGSdVJBJSDBJHBS'\ntest ", 34 | extension: 'md', 35 | name: 'a;sdjf.md', 36 | path: 'a;sdjf.md', 37 | ctime: '1660408861294', 38 | mtime: '1667067754540', 39 | }, 40 | ]; 41 | 42 | export const nodes2: Partial[] = [ 43 | { 44 | content: 45 | '---\nfont-matter: string\npublished: false\narray:\n\t- one\n\t- true\n\t- 3\n---\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n \nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', 46 | extension: 'md', 47 | name: 'Test note two.md', 48 | path: 'child one/child two/Test note two.md', 49 | ctime: '1670708458330', 50 | mtime: '1670708462758', 51 | }, 52 | { 53 | content: 54 | '---\nfont-matter: string\npublished: false\narray:\n\t- one\n\t- true\n\t- 3\n---\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n \nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', 55 | extension: 'md', 56 | name: 'Test note one.md', 57 | path: 'child one/child two/Test note one.md', 58 | ctime: '1670708450870', 59 | mtime: '1670708457106', 60 | }, 61 | { 62 | content: 63 | '---\nfont-matter: string\npublished: false\narray:\n\t- one\n\t- true\n\t- 3\n---\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n \nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', 64 | extension: 'md', 65 | name: 'Test note two.md', 66 | path: 'child one/Test note two.md', 67 | ctime: '1670708405795', 68 | mtime: '1670708412574', 69 | }, 70 | { 71 | content: 72 | '---\nfont-matter: string\npublished: true\narray:\n\t- one\n\t- true\n\t- 3\n---\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n \nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', 73 | extension: 'md', 74 | name: 'Test note one.md', 75 | path: 'child one/Test note one.md', 76 | ctime: '1670708397467', 77 | mtime: '1670708403815', 78 | }, 79 | { 80 | content: 81 | '---\nfont-matter: string\npublished: false\narray:\n\t- one\n\t- true\n\t- 3\n---\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n \nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', 82 | extension: 'md', 83 | name: 'Test note two.md', 84 | path: 'Test note two.md', 85 | ctime: '1670708356879', 86 | mtime: '1670708364399', 87 | }, 88 | { 89 | content: 90 | '---\nfont-matter: string\npublished: false\narray:\n\t- one\n\t- true\n\t- 3\n---\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n \nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', 91 | extension: 'md', 92 | name: 'Test note one.md', 93 | path: 'Test note one.md', 94 | ctime: '1670708115266', 95 | mtime: '1670708350323', 96 | }, 97 | ]; 98 | -------------------------------------------------------------------------------- /server/__tests__/mockData/users.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '../../types'; 2 | 3 | export const users: Partial[] = [ 4 | { 5 | username: 'Smokey Muffins', 6 | password: 'aj3^fkO3U#jw@#%FFle#@', 7 | }, 8 | { 9 | username: 'teapot42069', 10 | password: 'notverysecure', 11 | }, 12 | { 13 | username: 'the_best_dog', 14 | password: 'itsa[]Dassword12#$%', 15 | }, 16 | ]; 17 | -------------------------------------------------------------------------------- /server/__tests__/mockData/vaults.ts: -------------------------------------------------------------------------------- 1 | import type { Vault } from '../../types'; 2 | 3 | export const vaults: Partial[] = [ 4 | { 5 | name: 'TestingVault', 6 | }, 7 | { 8 | name: 'testvault2', 9 | }, 10 | ]; 11 | -------------------------------------------------------------------------------- /server/__tests__/setupDb.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import app from '../server'; 3 | import { db } from '../db/index'; 4 | import { afterAll, beforeAll, vi } from 'vitest'; 5 | import { rmSync, statSync } from 'node:fs'; 6 | 7 | export const server = request.agent(app); 8 | 9 | beforeAll(() => { 10 | process.env.TEST_ENV = 'true'; 11 | process.env.JWT_REFRESH_SECRET = 'test_Refresh'; 12 | process.env.JWT_ACCESS_SECRET = 'test_Access'; 13 | }); 14 | 15 | afterAll(async () => { 16 | db.close(); 17 | }); 18 | -------------------------------------------------------------------------------- /server/__tests__/user.test.ts: -------------------------------------------------------------------------------- 1 | import { orm } from '../db/orm'; 2 | import { server } from './setupDb'; 3 | import { users } from './mockData/users'; 4 | import { vaults } from './mockData/vaults'; 5 | import { nodes1, nodes2 } from './mockData/nodes'; 6 | import { it, describe, expect, beforeAll } from 'vitest'; 7 | 8 | describe('/api/user', () => { 9 | const secret = process.env.CLIENT_SECRET; 10 | 11 | it('should create a user', async () => { 12 | const response = await server 13 | .post('/api/user') 14 | .send({ ...users[0], secret }) 15 | .set('Accept', 'application/json') 16 | .expect('Content-Type', /json/) 17 | .expect(200); 18 | 19 | const [access_token, refresh_token] = response.headers['set-cookie']; 20 | 21 | expect(access_token).toBeDefined(); 22 | expect(refresh_token).toBeDefined(); 23 | expect(response.body.username).toEqual(users[0].username); 24 | expect(response.body.message).toEqual('User created!'); 25 | 26 | const stmnt = orm.getUser(); 27 | const user = stmnt.get({ 28 | username: response.body.username, 29 | }); 30 | 31 | expect(user).toBeDefined(); 32 | expect(user?.username).toEqual(response.body.username); 33 | }); 34 | 35 | it('should throw an error if username already exists', async () => { 36 | await server 37 | .post('/api/user') 38 | .send({ ...users[0], secret }) 39 | .set('Accept', 'application/json') 40 | .expect('Content-Type', /json/) 41 | .expect(400); 42 | }); 43 | 44 | it('should allow multiple users', async () => { 45 | const response = await server 46 | .post('/api/user') 47 | .send({ ...users[1], secret }) 48 | .set('Accept', 'application/json') 49 | .expect('Content-Type', /json/) 50 | .expect(200); 51 | 52 | const [access_token, refresh_token] = response.headers['set-cookie']; 53 | 54 | expect(access_token).toBeDefined(); 55 | expect(refresh_token).toBeDefined(); 56 | expect(response.body.username).toEqual(users[1].username); 57 | expect(response.body.message).toEqual('User created!'); 58 | const stmnt = orm.getUser(); 59 | const user = stmnt.get({ 60 | username: response.body.username, 61 | }); 62 | 63 | expect(user).toBeDefined(); 64 | expect(user?.username).toEqual(response.body.username); 65 | }); 66 | 67 | it('should delete the current user', async () => { 68 | const response = await server 69 | .delete('/api/user') 70 | .send({ username: users[1].username, secret }) 71 | .set('Accept', 'application/json') 72 | .expect('Content-Type', /json/) 73 | .expect(200); 74 | 75 | const [access_token, refresh_token] = response.headers['set-cookie']; 76 | 77 | expect(access_token).toBeDefined(); 78 | expect(refresh_token).toBeDefined(); 79 | expect(response.body.message).toEqual( 80 | `${users[1].username} and associated vault(s) deleted successfully`, 81 | ); 82 | 83 | const stmnt = orm.getUser(); 84 | const user = stmnt.get({ 85 | username: users[1].username, 86 | }); 87 | 88 | expect(user).toBeUndefined(); 89 | }); 90 | }); 91 | 92 | describe('/api/login', () => { 93 | it('should login a user with the correct credentials', async () => { 94 | const response = await server 95 | .post('/api/login') 96 | .send(users[0]) 97 | .set('Accept', 'application/json') 98 | .expect('Content-Type', /json/) 99 | .expect(200); 100 | 101 | const [access_token, refresh_token] = response.headers['set-cookie']; 102 | 103 | expect(access_token).toBeDefined(); 104 | expect(refresh_token).toBeDefined(); 105 | }); 106 | 107 | it('should not login a user if credentials are incorrect', async () => { 108 | await server 109 | .post('/api/login') 110 | .send({ username: users[1].username, password: 'thispasswordiswrong' }) 111 | .expect(401); 112 | }); 113 | 114 | it('should not login a user that does not exist', async () => { 115 | await server.post('/api/login').send(users[2]).expect(401); 116 | }); 117 | }); 118 | 119 | describe('/api/vault', async () => { 120 | it('should create a vault', async () => { 121 | const response = await server 122 | .put('/api/vault') 123 | .send({ 124 | nodes: nodes1, 125 | vault: vaults[0].name, 126 | }) 127 | .expect(200); 128 | 129 | expect(response.body.message).toEqual( 130 | "Vault TestingVault was successfully sync'd!", 131 | ); 132 | }); 133 | 134 | it('should get a vault', async () => { 135 | await server 136 | .get(`/api/vault?vault=${vaults[0].name}`) 137 | .send({ 138 | nodes: nodes1, 139 | vault: vaults[0].name, 140 | }) 141 | .expect(200); 142 | }); 143 | 144 | it('should return 404 if the vault was not found', async () => { 145 | const response = await server 146 | .get(`/api/vault?vault=${vaults[1].name}`) 147 | .expect(404); 148 | 149 | expect(response.body.error).toEqual( 150 | `No vault ${vaults[1].name} to send. Check the vault name and make sure you've sync'd at least once.`, 151 | ); 152 | }); 153 | it('should return 400 if no vault was sent', async () => { 154 | const response = await server.get(`/api/vault?vault=`).expect(400); 155 | 156 | expect(response.body.error).toEqual( 157 | 'No vault was sent in the request. Make sure the Vault is set in the plugin options', 158 | ); 159 | }); 160 | 161 | it('should return 401 if the user is unauthorized', async () => { 162 | // logout a user 163 | await server 164 | .post('/api/logout') 165 | .send({ username: users[0].username }) 166 | .expect(200); 167 | 168 | await server.get(`/api/vault?vault=${vaults[0].name}`).expect(401); 169 | }); 170 | }); 171 | 172 | describe('/api/refresh_token', () => { 173 | it('should refresh tokens when a user is logged in', async () => { 174 | await server 175 | .post('/api/login') 176 | .send({ ...users[0] }) 177 | .expect(200); 178 | const response = await server 179 | .post('/api/refresh_token') 180 | .send({ username: users[0].username }) 181 | .expect(200); 182 | 183 | expect(response.body.message).toEqual('Tokens Refreshed'); 184 | }); 185 | 186 | it('should send a 401 if the session is expired', async () => { 187 | const stmnt = orm.updateUser(); 188 | stmnt.run({ 189 | username: users[0].username, 190 | refreshToken: 0, 191 | }); 192 | 193 | const response = await server 194 | .post('/api/refresh_token') 195 | .send({ username: users[0].username }) 196 | .expect(401); 197 | 198 | expect(response.body.message).toEqual( 199 | 'Session expired. Please log in again.', 200 | ); 201 | }); 202 | }); 203 | 204 | describe('/api/logout', () => { 205 | it('should logout a user', async () => { 206 | const response = await server 207 | .post('/api/logout') 208 | .send({ username: users[0].username }) 209 | .expect(200); 210 | 211 | const [access_token, refresh_token] = response.headers['set-cookie']; 212 | 213 | expect(access_token).toEqual( 214 | 'access_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=None', 215 | ); 216 | expect(refresh_token).toEqual( 217 | 'refresh_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=None', 218 | ); 219 | expect(response.body.message).toEqual( 220 | `Logged out ${users[0].username} successfully!`, 221 | ); 222 | }); 223 | 224 | it('should send a 404 if a username is not defined', async () => { 225 | await server 226 | .post('/api/logout') 227 | .send({ username: 'thisuserdoesntexist' }) 228 | .expect(404); 229 | }); 230 | 231 | describe('/api/blog', () => { 232 | it('should return nodes marked as published through hashtag or frontmatter', async () => { 233 | const response = await server 234 | .get(`/api/blog?vault=${vaults[0].name}`) 235 | .expect(200); 236 | 237 | console.log(response.body); 238 | 239 | expect(response.body).toEqual([ 240 | { 241 | content: 242 | '---\nfont: matter\nteset: value\n---\n#tag1 #tag2 #unpublished\n\n# My test note\nHere is a paragraph with some text blah blah blah.\n\nLinebreak!\n\n\n', 243 | title: 'test note', 244 | slug: 'test-note', 245 | createdAt: '8/13/2022', 246 | modifiedAt: '8/13/2022', 247 | }, 248 | { 249 | title: 'another test note', 250 | slug: 'another-test-note', 251 | createdAt: '10/29/2022', 252 | modifiedAt: '10/29/2022', 253 | content: 254 | '---\n' + 255 | 'font: matter\n' + 256 | 'teset: value\n' + 257 | '---\n' + 258 | '#tag1 #tag2 \n' + 259 | '\n' + 260 | '# My test note 2\n' + 261 | 'Here is a paragraph with some text blah blah blah.\n' + 262 | '\n' + 263 | 'Linebreak!\n' + 264 | '\n', 265 | }, 266 | { 267 | content: 268 | '---\nfont: matter\nteset: value\n---\n#tag1 #tag2 #unpublished\n\n# My test note 2\nHere is a paragraph with some text blah blah blah.\n\nLinebreak!\n', 269 | createdAt: '8/13/2022', 270 | modifiedAt: '10/29/2022', 271 | slug: 'another-test-note-1', 272 | title: 'another test note 1', 273 | }, 274 | ]); 275 | }); 276 | }); 277 | }); 278 | -------------------------------------------------------------------------------- /server/controllers/blog.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express'; 2 | import type { Node } from '../types'; 3 | import { LOCALE } from '../utils/consts'; 4 | import { orm } from '../db/orm'; 5 | 6 | export const blogControllers = { 7 | get: async (req: Request, res: Response) => { 8 | const { vault } = req.query; 9 | if (!vault) { 10 | res.status(400).json({ 11 | message: 12 | 'No vault name provided.\nThe vault name should be available as a query string parameter vault=vaultName ', 13 | }); 14 | return; 15 | } 16 | 17 | try { 18 | const stmnt = orm.getNodes(); 19 | const vaultFromDb = stmnt.all({ vault: vault as string }); 20 | 21 | if (!vaultFromDb) { 22 | res.status(404).json({ 23 | message: `Vault named ${vault} was not found in the DB. Please make sure the vault exists in the database`, 24 | }); 25 | return; 26 | } 27 | 28 | const publishedNodes = vaultFromDb 29 | .filter(({ content }: Node) => { 30 | return ( 31 | content.includes('published: true') || 32 | content.includes('#published') || 33 | content.includes('#unpublished') || 34 | content.includes('#deleted') 35 | ); 36 | }) 37 | .map(({ name, content, ctime, mtime }: Node) => { 38 | const title = name.replace(/\.md$/g, ''); 39 | const slug = title.replace(/\s/g, '-').toLowerCase(); 40 | const createdAt = new Date(Number(ctime)).toLocaleDateString(LOCALE); 41 | const modifiedAt = new Date(Number(mtime)).toLocaleDateString(LOCALE); 42 | 43 | content = content.replace('#published', ''); 44 | return { 45 | title, 46 | slug, 47 | content, 48 | createdAt, 49 | modifiedAt, 50 | }; 51 | }); 52 | 53 | if (!publishedNodes) { 54 | res.status(404).json({ 55 | message: `Vault ${vault} has not published nodes. Please publish a node and try again.`, 56 | }); 57 | return; 58 | } 59 | 60 | res.status(200).json(publishedNodes); 61 | } catch (error) { 62 | console.error(error); 63 | res.status(500).json({ 64 | message: 65 | error instanceof Error 66 | ? error.message 67 | : 'Something went wrong on the server', 68 | error: error, 69 | }); 70 | return; 71 | } 72 | }, 73 | }; 74 | -------------------------------------------------------------------------------- /server/controllers/index.ts: -------------------------------------------------------------------------------- 1 | import { vaultControllers } from './vault'; 2 | import { loginControllers } from './login'; 3 | import { userControllers } from './user'; 4 | import { refreshControllers } from './refreshToken'; 5 | import { logoutControllers } from './logout'; 6 | 7 | export { 8 | vaultControllers, 9 | loginControllers, 10 | userControllers, 11 | refreshControllers, 12 | logoutControllers, 13 | }; 14 | -------------------------------------------------------------------------------- /server/controllers/login.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express'; 2 | export const loginControllers = { 3 | post: async (req: Request, res: Response) => { 4 | res.json({ 5 | message: `Successfully logged in ${req.body.username}.`, 6 | username: req.body.username, 7 | }); 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /server/controllers/logout.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express'; 2 | import { orm } from '../db/orm'; 3 | import { clearCookies } from '../utils/clearCookies'; 4 | 5 | export const logoutControllers = { 6 | post: async (req: Request, res: Response) => { 7 | const { username } = req.body; 8 | try { 9 | const stmnt = orm.updateUser(); 10 | const user = stmnt.run({ 11 | refreshToken: '', 12 | username, 13 | }); 14 | 15 | if (!user.changes) { 16 | throw new Error('user not found'); 17 | } 18 | } catch (error) { 19 | res.status(404).json({ 20 | message: `Could not find ${username} in database.`, 21 | error: error, 22 | }); 23 | return; 24 | } 25 | 26 | console.log(`logging out ${username}...`); 27 | clearCookies(res); 28 | delete req.user; 29 | 30 | res.json({ message: `Logged out ${username} successfully!` }); 31 | return; 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /server/controllers/refreshToken.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express'; 2 | import { orm } from '../db/orm'; 3 | import { generateToken } from '../utils/generateToken'; 4 | import jwt from 'jsonwebtoken'; 5 | 6 | export const refreshControllers = { 7 | post: async (req: Request, res: Response) => { 8 | const { username } = req.body; 9 | // TODO: Make this composable :eyes: 10 | const cookie = req.get('cookie'); 11 | const matches = /refresh_token=(?[\w\D]+);?/g.exec( 12 | cookie as string, 13 | ); 14 | 15 | if (!matches) { 16 | res 17 | .status(401) 18 | .json({ message: 'Session expired. Please log in again.' }); 19 | return; 20 | } 21 | 22 | if (matches && matches.groups) { 23 | const refreshToken = matches.groups.refreshToken; 24 | try { 25 | const stmnt = orm.getUser('refreshToken'); 26 | const user = stmnt.get({ 27 | username, 28 | }); 29 | 30 | if (user?.refreshToken === refreshToken) { 31 | const isValid = jwt.verify( 32 | refreshToken, 33 | process.env.JWT_REFRESH_SECRET as string, 34 | ); 35 | if (isValid) { 36 | const newAccessToken = await generateToken( 37 | user.id, 38 | user.username, 39 | '15m', 40 | 'access', 41 | ); 42 | const newRefreshToken = await generateToken( 43 | user.id, 44 | user.username, 45 | '7d', 46 | 'refresh', 47 | ); 48 | 49 | res.cookie('access_token', newAccessToken, { 50 | maxAge: 15 * 60 * 1000, // 15 min 51 | sameSite: 'none', 52 | secure: true, 53 | httpOnly: true, 54 | }); 55 | res.cookie('refresh_token', newRefreshToken, { 56 | maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week 57 | sameSite: 'none', 58 | secure: true, 59 | httpOnly: true, 60 | }); 61 | 62 | res.json({ message: 'Tokens Refreshed' }); 63 | return; 64 | } 65 | } 66 | } catch (error) { 67 | console.log(error); 68 | res.status(500).json(); 69 | return; 70 | } 71 | } 72 | }, 73 | }; 74 | -------------------------------------------------------------------------------- /server/controllers/user.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express'; 2 | import { orm } from '../db/orm'; 3 | import bcrypt from 'bcrypt'; 4 | import { generateToken } from '../utils/generateToken'; 5 | import { clearCookies } from '../utils/clearCookies'; 6 | import { randomUUID } from 'node:crypto'; 7 | 8 | const saltRounds = 10; 9 | export const userControllers = { 10 | // create a user 11 | post: async (req: Request, res: Response) => { 12 | const { username, password: plaintextPw, secret } = req.body; 13 | if (process.env.CLIENT_SECRET !== secret) { 14 | res.status(403).json({ 15 | message: 16 | 'Invalid client secret. If you are a valid user of the server, ask the owner for the client secret', 17 | }); 18 | return; 19 | } 20 | // salt and hash pw 21 | try { 22 | const hashedPw = await bcrypt.hash(plaintextPw, saltRounds); 23 | // create new user 24 | const newUserStmnt = orm.createUser(); 25 | 26 | const userId = randomUUID(); 27 | newUserStmnt.run({ 28 | id: userId, 29 | username, 30 | password: hashedPw, 31 | }); 32 | 33 | const accessToken = await generateToken( 34 | userId, 35 | username, 36 | '15m', 37 | 'access', 38 | ); 39 | const refreshToken = await generateToken( 40 | userId, 41 | username, 42 | '7d', 43 | 'refresh', 44 | ); 45 | 46 | if (accessToken && refreshToken) { 47 | res.cookie('access_token', accessToken, { 48 | maxAge: 15 * 60 * 1000, // 15 min 49 | sameSite: 'none', 50 | secure: process.env.TEST_ENV === 'true' ? false : true, 51 | httpOnly: true, 52 | }); 53 | res.cookie('refresh_token', refreshToken, { 54 | maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week 55 | sameSite: 'none', 56 | secure: process.env.TEST_ENV === 'true' ? false : true, 57 | httpOnly: true, 58 | }); 59 | // send response to user 60 | res.json({ 61 | message: 'User created!', 62 | username: username, 63 | }); 64 | } 65 | } catch (error) { 66 | if (error) { 67 | console.error(error); 68 | res.status(400).json({ 69 | message: 'Could not create user. Is the username already in use?', 70 | error: error, 71 | }); 72 | } else { 73 | console.error(error); 74 | // if error, send error 75 | res.status(500).json({ error: error }); 76 | } 77 | } 78 | }, 79 | delete: async (req: Request, res: Response) => { 80 | console.log(req.user); 81 | if (!req.user) { 82 | res.status(401).json({ 83 | message: 'Please log in to the user account that needs to be deleted', 84 | }); 85 | return; 86 | } 87 | try { 88 | const username = req.user.username; 89 | 90 | const deletedUserStmnt = orm.deleteUser(); 91 | const deleted = deletedUserStmnt.run({ 92 | username, 93 | }); 94 | 95 | if (deleted) { 96 | clearCookies(res); 97 | delete req.user; 98 | 99 | res.status(200).json({ 100 | message: `${username} and associated vault(s) deleted successfully`, 101 | }); 102 | return; 103 | } else { 104 | res 105 | .status(404) 106 | .json({ message: 'No user was deleted. Does the user exist?' }); 107 | return; 108 | } 109 | } catch (error) { 110 | console.error(error); 111 | res.status(500).json(); 112 | return; 113 | } 114 | }, 115 | }; 116 | -------------------------------------------------------------------------------- /server/controllers/vault.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express'; 2 | import type { Vault } from '../types'; 3 | import { orm } from '../db/orm'; 4 | import { createOrUpdateNodes } from '../utils/createOrUpdateNodes'; 5 | import { randomUUID } from 'node:crypto'; 6 | 7 | const vaultStmnt = orm.getNodesOnVault(); 8 | 9 | export const vaultControllers = { 10 | get: async (req: Request, res: Response) => { 11 | const vault = req.query.vault as string; 12 | const errorMessage = `No vault ${vault} to send. Check the vault name and make sure you've sync'd at least once.`; 13 | if (!vault) { 14 | res.status(400).json({ 15 | error: 16 | 'No vault was sent in the request. Make sure the Vault is set in the plugin options', 17 | }); 18 | return; 19 | } 20 | if (!req.user) { 21 | res.status(401).json({ message: 'Please login' }); 22 | return; 23 | } 24 | try { 25 | // get vault from DB 26 | const nodesFromVault = vaultStmnt.all({ 27 | vault, 28 | username: req.user.username, 29 | }); 30 | if (!nodesFromVault.length) { 31 | res.status(404).json({ 32 | error: errorMessage, 33 | }); 34 | return; 35 | } 36 | res.json({ 37 | name: vault, 38 | nodes: nodesFromVault, 39 | }); 40 | return; 41 | } catch (error) { 42 | console.error(error); 43 | res.status(500).json({ 44 | error: errorMessage, 45 | }); 46 | return; 47 | } 48 | }, 49 | put: async (req: Request, res: Response) => { 50 | try { 51 | const { 52 | body: { nodes, vault }, 53 | } = req; 54 | let resultVault; 55 | if (!vault) { 56 | res.status(400).json({ error: 'No vault was received' }); 57 | } 58 | if (!req.user) { 59 | res.status(401).json({ message: 'Please login' }); 60 | } else if (nodes && vault) { 61 | const foundVault = vaultStmnt.all({ 62 | vault, 63 | username: req.user.username, 64 | }); 65 | 66 | let vaultId: Vault['id']; 67 | 68 | if (foundVault.length > 0) { 69 | console.log(`Found vault ${vault} Adding nodes...`); 70 | vaultId = foundVault[0].vault_id; 71 | } else { 72 | const newVaultStmnt = orm.createVault(); 73 | vaultId = randomUUID(); 74 | newVaultStmnt.run({ 75 | id: vaultId, 76 | name: vault, 77 | user: req.user.username, 78 | }); 79 | } 80 | resultVault = await createOrUpdateNodes({ 81 | nodes, 82 | vaultId, 83 | }); 84 | 85 | res.json({ 86 | message: `Vault ${vault} was successfully sync'd!`, 87 | vault: resultVault, 88 | }); 89 | } 90 | } catch (error) { 91 | console.error(error); 92 | res.status(500).json({ 93 | message: 'Something went wrong', 94 | error: error, 95 | }); 96 | return; 97 | } 98 | }, 99 | }; 100 | -------------------------------------------------------------------------------- /server/db/base_schema.sql: -------------------------------------------------------------------------------- 1 | -- saving this for historical purposes 2 | CREATE TABLE IF NOT EXISTS "User" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "username" TEXT NOT NULL, 5 | "password" TEXT NOT NULL, 6 | "refreshToken" TEXT 7 | ); 8 | CREATE TABLE IF NOT EXISTS "Vault" ( 9 | "id" TEXT NOT NULL PRIMARY KEY, 10 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 11 | "name" TEXT NOT NULL, 12 | "user" TEXT NOT NULL, 13 | CONSTRAINT "Vault_user_fkey" FOREIGN KEY ("user") REFERENCES "User" ("username") ON DELETE CASCADE ON UPDATE CASCADE 14 | ); 15 | CREATE TABLE IF NOT EXISTS "Node" ( 16 | "id" TEXT NOT NULL PRIMARY KEY, 17 | "vaultId" TEXT NOT NULL, 18 | "content" TEXT NOT NULL, 19 | "extension" TEXT NOT NULL, 20 | "name" TEXT NOT NULL, 21 | "path" TEXT NOT NULL, 22 | "ctime" TEXT NOT NULL, 23 | "mtime" TEXT NOT NULL, 24 | CONSTRAINT "Node_vaultId_fkey" FOREIGN KEY ("vaultId") REFERENCES "Vault" ("id") ON DELETE CASCADE ON UPDATE CASCADE 25 | ); 26 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); 27 | CREATE UNIQUE INDEX "Node_id_vault_key" ON "Node"("id", "vaultId"); 28 | -- CREATE UNIQUE INDEX "Vault_id_key" ON "Vault"("vault"); 29 | -------------------------------------------------------------------------------- /server/db/index.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from './schema'; 2 | import Database from 'better-sqlite3'; 3 | import dotenv from 'dotenv'; 4 | dotenv.config(); 5 | 6 | const DB_URL = process.env.DATABASE_URL; 7 | if (!DB_URL) { 8 | throw new Error('DATABASE_URL not set.'); 9 | } 10 | 11 | export const db = new Database(DB_URL); 12 | 13 | const schema = new Schema(); 14 | 15 | const prepareTables = db.transaction(() => { 16 | db.prepare(schema.userTable()).run(); 17 | db.prepare(schema.vaultTable()).run(); 18 | db.prepare(schema.nodeTable()).run(); 19 | db.prepare(schema.usernameIndex()).run(); 20 | db.prepare(schema.vaultNodeIndex()).run(); 21 | }); 22 | 23 | prepareTables(); 24 | -------------------------------------------------------------------------------- /server/db/orm.ts: -------------------------------------------------------------------------------- 1 | import { db } from '.'; 2 | import type { Node, User, Vault } from '../types'; 3 | 4 | class ORM { 5 | db = db; 6 | 7 | getNodes() { 8 | const query = ` 9 | Select Node.* 10 | FROM Node 11 | JOIN Vault ON Node.vaultId = Vault.id 12 | WHERE Vault.name=@vault;`; 13 | 14 | return this.db.prepare(query); 15 | } 16 | 17 | getUser(...fields: string[]) { 18 | const query = ` 19 | SELECT id, username ${fields && fields.length > 0 ? `, ${fields.join(', ')}` : ''} 20 | FROM User 21 | WHERE username = @username;`; 22 | 23 | return this.db.prepare(query); 24 | } 25 | 26 | /** 27 | * updates the user refreshToken 28 | */ 29 | updateUser() { 30 | const query = ` 31 | UPDATE User 32 | SET refreshToken = @refreshToken 33 | WHERE username = @username;`; 34 | 35 | return this.db.prepare(query); 36 | } 37 | 38 | createUser() { 39 | const query = ` 40 | INSERT INTO User (id, username, password) 41 | VALUES (@id, @username, @password);`; 42 | 43 | return this.db.prepare(query); 44 | } 45 | 46 | deleteUser() { 47 | const query = ` 48 | DELETE FROM user 49 | WHERE username=@username`; 50 | 51 | return this.db.prepare(query); 52 | } 53 | 54 | getNodesOnVault() { 55 | const query = ` 56 | SELECT Vault.id as vault_id, Node.* 57 | FROM Vault 58 | LEFT JOIN Node ON Node.vaultId = Vault.id 59 | WHERE Vault.name=@vault AND Vault.user=@username;`; 60 | 61 | return this.db.prepare( 62 | query, 63 | ); 64 | } 65 | 66 | getAllNodesOnVault() { 67 | const query = ` 68 | SELECT v.id, v.name, v.user, v.createdAt, n.id AS node_id, n.name AS node_name 69 | FROM Vault v 70 | LEFT JOIN Node n ON n.vaultId = v.id 71 | WHERE v.id = @id 72 | LIMIT 1;`; 73 | 74 | return this.db.prepare(query); 75 | } 76 | 77 | createVault() { 78 | const query = ` 79 | INSERT INTO Vault (id, name, user) 80 | VALUES (@id, @name, @user);`; 81 | 82 | return this.db.prepare(query); 83 | } 84 | 85 | getNode() { 86 | const query = ` 87 | SELECT id, vaultId, content 88 | FROM Node 89 | WHERE Node.vaultId=@vaultId AND Node.path=@path;`; 90 | 91 | return this.db.prepare(query); 92 | } 93 | 94 | updateNode() { 95 | const query = ` 96 | UPDATE Node 97 | SET 98 | path=@path, 99 | content=@content, 100 | name=@name, 101 | extension=@extension, 102 | ctime=@ctime, 103 | mtime=@mtime 104 | WHERE id=@id AND vaultId=@vaultId;`; 105 | 106 | return this.db.prepare(query); 107 | } 108 | 109 | createNode() { 110 | const query = ` 111 | INSERT INTO Node (id, path, content, name, extension, ctime, mtime, vaultId) 112 | VALUES (@id, @path, @content, @name, @extension, @ctime, @mtime, @vaultId);`; 113 | 114 | return this.db.prepare(query); 115 | } 116 | } 117 | 118 | export const orm = new ORM(); 119 | -------------------------------------------------------------------------------- /server/db/schema.ts: -------------------------------------------------------------------------------- 1 | export class Schema { 2 | userTable() { 3 | return `CREATE TABLE IF NOT EXISTS "User" ( 4 | "id" TEXT NOT NULL PRIMARY KEY, 5 | "username" TEXT NOT NULL, 6 | "password" TEXT NOT NULL, 7 | "refreshToken" TEXT 8 | );`; 9 | } 10 | vaultTable() { 11 | return `CREATE TABLE IF NOT EXISTS "Vault" ( 12 | "id" TEXT NOT NULL PRIMARY KEY, 13 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 | "name" TEXT NOT NULL, 15 | "user" TEXT NOT NULL, 16 | CONSTRAINT "Vault_user_fkey" FOREIGN KEY ("user") REFERENCES "User" ("username") ON DELETE CASCADE ON UPDATE CASCADE 17 | );`; 18 | } 19 | 20 | nodeTable() { 21 | return `CREATE TABLE IF NOT EXISTS "Node" ( 22 | "id" TEXT NOT NULL, 23 | "vaultId" TEXT NOT NULL, 24 | "content" TEXT NOT NULL, 25 | "extension" TEXT NOT NULL, 26 | "name" TEXT NOT NULL, 27 | "path" TEXT NOT NULL, 28 | "ctime" TEXT NOT NULL, 29 | "mtime" TEXT NOT NULL, 30 | CONSTRAINT "Node_vaultId_fkey" FOREIGN KEY ("vaultId") REFERENCES "Vault" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 31 | PRIMARY KEY("id", "vaultId") 32 | );`; 33 | } 34 | usernameIndex() { 35 | return `CREATE UNIQUE INDEX IF NOT EXISTS "User_username_key" ON "User"("username");`; 36 | } 37 | 38 | vaultNodeIndex() { 39 | return `CREATE UNIQUE INDEX IF NOT EXISTS "Node_id_vault_key" ON "Node"("id", "vaultId");`; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /server/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 2 | import tsParser from '@typescript-eslint/parser'; 3 | import path from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import js from '@eslint/js'; 6 | import { FlatCompat } from '@eslint/eslintrc'; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | const compat = new FlatCompat({ 11 | baseDirectory: __dirname, 12 | recommendedConfig: js.configs.recommended, 13 | allConfig: js.configs.all, 14 | }); 15 | 16 | export default [ 17 | ...compat.extends( 18 | 'eslint:recommended', 19 | 'plugin:@typescript-eslint/recommended', 20 | ), 21 | { 22 | ignores: ['./build/'], 23 | plugins: { 24 | '@typescript-eslint': typescriptEslint, 25 | }, 26 | 27 | languageOptions: { 28 | globals: {}, 29 | parser: tsParser, 30 | ecmaVersion: 'latest', 31 | sourceType: 'module', 32 | }, 33 | 34 | rules: { 35 | indent: ['error', 4], 36 | 'linebreak-style': ['error', 'unix'], 37 | quotes: ['error', 'double'], 38 | semi: ['error', 'always'], 39 | 'space-in-brackets': ['error', 'always'], 40 | 'newline-per-chained-call': ['error', 'always'], 41 | }, 42 | }, 43 | ]; 44 | -------------------------------------------------------------------------------- /server/middleware/loginMiddleware.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from 'express'; 2 | import bcrypt from 'bcrypt'; 3 | import { generateToken } from '../utils/generateToken'; 4 | import { orm } from '../db/orm'; 5 | 6 | export const loginMiddleware = async ( 7 | req: Request, 8 | res: Response, 9 | next: NextFunction, 10 | ) => { 11 | const { username, password: plaintextPw } = req.body; 12 | const stmnt = orm.getUser('password'); 13 | 14 | const user = stmnt.get({ 15 | username, 16 | }); 17 | 18 | if (!user) { 19 | res.status(401).json({ 20 | message: `Username ${username} was not found in the database\nIf you are sure the user exists, check the username and try again.`, 21 | }); 22 | return; 23 | } 24 | 25 | const passwordsMatch = 26 | user && (await bcrypt.compare(plaintextPw, user.password)); 27 | 28 | if (!passwordsMatch) { 29 | res.status(401).json({ 30 | message: 31 | 'Password received does not match stored value. Please check your password and try again.', 32 | }); 33 | return; 34 | } 35 | 36 | if (user && user.id && passwordsMatch) { 37 | const accessToken = await generateToken( 38 | user.id, 39 | user.username, 40 | '15m', 41 | 'access', 42 | ); 43 | const refreshToken = await generateToken( 44 | user.id, 45 | user.username, 46 | '7d', 47 | 'refresh', 48 | ); 49 | 50 | res.cookie('access_token', accessToken, { 51 | maxAge: 15 * 60 * 1000, // 15 min 52 | sameSite: 'none', 53 | secure: process.env.TEST_ENV === 'true' ? false : true, 54 | httpOnly: true, 55 | }); 56 | res.cookie('refresh_token', refreshToken, { 57 | maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week 58 | sameSite: 'none', 59 | secure: process.env.TEST_ENV === 'true' ? false : true, 60 | httpOnly: true, 61 | }); 62 | 63 | req.user = { username: user.username, userId: user.id }; 64 | 65 | next(); 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /server/middleware/verifyAuthMiddleware.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from 'express'; 2 | import jwt from 'jsonwebtoken'; 3 | import { ReqUser } from '../types'; 4 | 5 | export const verifyAuthMiddleware = async ( 6 | req: Request, 7 | res: Response, 8 | next: NextFunction, 9 | ) => { 10 | try { 11 | // get the cookie header 12 | const cookie = req.get('cookie'); 13 | if (!cookie) { 14 | res.status(401).json({ message: 'Not Authorized' }); 15 | return; 16 | } 17 | // grab the access token from the header 18 | const matches = /^access_token=(?[\w\D]+);/g.exec( 19 | cookie as string, 20 | ); 21 | if (matches && matches.groups) { 22 | const accessToken = matches.groups.accessToken; 23 | 24 | if (!accessToken) { 25 | res.status(401).json({ message: 'Not Authorized' }); 26 | return; 27 | } 28 | 29 | const user = jwt.verify( 30 | accessToken, 31 | process.env.JWT_ACCESS_SECRET as string, 32 | ) as ReqUser; 33 | if (user) { 34 | req.user = user; 35 | } else { 36 | res.status(401).json({ message: 'Not Authorized' }); 37 | return; 38 | } 39 | next(); 40 | } else { 41 | // if we're reaching this point it's likely the access_token is expired. 42 | // Does it make sense to make the refresh endpoint a middleware instead 43 | // and call next() here instead? Then we could send 401 only if 44 | // the refresh token is expired 45 | res.status(401).json({ message: 'Not Authorized' }); 46 | return; 47 | } 48 | } catch (error) { 49 | console.error(error); 50 | res.status(500).json({ 51 | error: error, 52 | }); 53 | return; 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-sync-server", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "./build/server.js", 6 | "files": [ 7 | "build", 8 | "db/schema.prisma" 9 | ], 10 | "scripts": { 11 | "build:prod": "tsc", 12 | "dev": "pnpm watch & NODE_ENV=development pnpm nodemon ./build/server.js", 13 | "start:prod": "NODE_ENV=production node ./build/server.js", 14 | "test": "vitest --run", 15 | "test:watch": "vitest --coverage", 16 | "watch": "tsc --watch" 17 | }, 18 | "keywords": [], 19 | "author": "CobyPear", 20 | "license": "MIT", 21 | "dependencies": { 22 | "bcrypt": "^5.1.1", 23 | "better-sqlite3": "^11.8.1", 24 | "cors": "^2.8.5", 25 | "dotenv": "^16.4.7", 26 | "express": "^4.21.2", 27 | "jsonwebtoken": "^9.0.2", 28 | "morgan": "^1.10.0", 29 | "node-html-parser": "^7.0.1", 30 | "winston": "^3.17.0" 31 | }, 32 | "devDependencies": { 33 | "@eslint/eslintrc": "^3.2.0", 34 | "@eslint/js": "^9.18.0", 35 | "@types/bcrypt": "^5.0.2", 36 | "@types/better-sqlite3": "^7.6.12", 37 | "@types/cors": "^2.8.17", 38 | "@types/express": "^5.0.0", 39 | "@types/jsonwebtoken": "^9.0.7", 40 | "@types/morgan": "^1.9.9", 41 | "@types/supertest": "^6.0.2", 42 | "@typescript-eslint/eslint-plugin": "^8.21.0", 43 | "@typescript-eslint/parser": "^8.21.0", 44 | "eslint": "^9.18.0", 45 | "nodemon": "^3.1.9", 46 | "prettier": "^3.4.2", 47 | "supertest": "^7.0.0", 48 | "typescript": "^5.7.3", 49 | "vitest": "^3.0.3", 50 | "vitest-environment-obsync": "workspace:*" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /server/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { userControllers } from '../controllers'; 4 | import { loginControllers } from '../controllers'; 5 | import { logoutControllers } from '../controllers'; 6 | import { refreshControllers } from '../controllers'; 7 | import { vaultControllers } from '../controllers'; 8 | 9 | import { loginMiddleware } from '../middleware/loginMiddleware'; 10 | import { verifyAuthMiddleware } from '../middleware/verifyAuthMiddleware'; 11 | 12 | const router = Router(); 13 | 14 | router 15 | .route('/user') 16 | .post(userControllers.post) 17 | .delete(verifyAuthMiddleware, userControllers.delete); 18 | router.route('/login').post(loginMiddleware, loginControllers.post); 19 | router.route('/logout').post(logoutControllers.post); 20 | router.route('/refresh_token').post(refreshControllers.post); 21 | router 22 | .route('/vault') 23 | .get(verifyAuthMiddleware, vaultControllers.get) 24 | .put(verifyAuthMiddleware, vaultControllers.put); 25 | 26 | export const routes = router; 27 | -------------------------------------------------------------------------------- /server/server.ts: -------------------------------------------------------------------------------- 1 | import type { CorsOptions } from 'cors'; 2 | import express from 'express'; 3 | import cors from 'cors'; 4 | import dotenv from 'dotenv'; 5 | 6 | import { morganMiddleware } from './utils/logger'; 7 | import { routes } from './routes'; 8 | import { blogControllers } from './controllers/blog'; 9 | 10 | dotenv.config(); 11 | const app = express(); 12 | const port = process.env.PORT || 8080; 13 | const host = process.env.HOST || 'localhost'; 14 | 15 | const corsOptions: CorsOptions = { 16 | allowedHeaders: ['Origin', 'Content-Type', 'Set-Cookie'], 17 | methods: 'GET,OPTIONS,POST,PUT,DELETE', 18 | origin: 'app://obsidian.md', 19 | credentials: true, 20 | preflightContinue: true, 21 | }; 22 | 23 | app.use(express.json({ limit: '250mb' })); 24 | app.use(express.urlencoded({ extended: true })); 25 | app.use(morganMiddleware); 26 | // ignore cors for blog route for now 27 | app.get('/api/blog', blogControllers.get); 28 | app.use(cors(corsOptions)); 29 | 30 | app.use('/api', routes); 31 | app.use('*', (req, res) => { 32 | res.status(404).json({ message: 'Route not found' }); 33 | }); 34 | 35 | app.listen(port, () => { 36 | console.log(`🌎 Server listening at http://${host}:${port}`); 37 | }); 38 | 39 | export default app; 40 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "CommonJS", 5 | "moduleResolution": "node", 6 | "forceConsistentCasingInFileNames": true, 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "noImplicitAny": true, 10 | "alwaysStrict": true, 11 | "noUnusedLocals": true, 12 | "skipLibCheck": true, 13 | "outDir": "build" 14 | }, 15 | "exclude": ["__tests__"] 16 | } 17 | -------------------------------------------------------------------------------- /server/types.d.ts: -------------------------------------------------------------------------------- 1 | import { JwtPayload } from 'jsonwebtoken'; 2 | 3 | interface User { 4 | id: string; 5 | username: string; 6 | password: string; 7 | refreshToken?: string; 8 | } 9 | 10 | interface Vault { 11 | id: string; 12 | createdAt: Date; 13 | name: User['username']; 14 | user: string; 15 | nodes?: Node[]; 16 | } 17 | 18 | interface Node { 19 | id: string; 20 | vaultId: Vault['id']; 21 | name: string; 22 | path: string; 23 | content: string; 24 | extension: string; 25 | ctime: string; 26 | mtime: string; 27 | } 28 | 29 | interface ReqUser extends JwtPayload { 30 | userId: User['id']; 31 | username: string; 32 | } 33 | declare global { 34 | namespace Express { 35 | export interface Request { 36 | user?: ReqUser; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/utils/clearCookies.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | 3 | export const clearCookies = (res: Response) => { 4 | res.clearCookie('access_token', { 5 | sameSite: 'none', 6 | secure: true, 7 | httpOnly: true, 8 | }); 9 | res.clearCookie('refresh_token', { 10 | sameSite: 'none', 11 | secure: true, 12 | httpOnly: true, 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /server/utils/consts.ts: -------------------------------------------------------------------------------- 1 | export const LOCALE = 2 | process.env.LOCALE || 3 | (process.env.LANG?.split('.')[0].replace('_', '-') ?? ''); 4 | -------------------------------------------------------------------------------- /server/utils/createOrUpdateNodes.ts: -------------------------------------------------------------------------------- 1 | import type { Node, Vault } from '../types'; 2 | import { randomUUID } from 'node:crypto'; 3 | import { orm } from '../db/orm'; 4 | 5 | export const createOrUpdateNodes = async ({ 6 | nodes, 7 | vaultId, 8 | }: { 9 | nodes: Node[]; 10 | vaultId: Vault['id']; 11 | }) => { 12 | for (const { content, name, extension, path, ctime, mtime } of nodes) { 13 | try { 14 | const nodeStmnt = orm.getNode(); 15 | const node = nodeStmnt.get({ vaultId, path }); 16 | if (node?.content && node.id) { 17 | console.debug(`Found node ${path}... Updating!`); 18 | const updateStmnt = orm.updateNode(); 19 | updateStmnt.run({ 20 | id: node.id, 21 | vaultId, 22 | path, 23 | content, 24 | name, 25 | extension, 26 | ctime, 27 | mtime, 28 | }); 29 | } else { 30 | const insertStmnt = orm.createNode(); 31 | insertStmnt.run({ 32 | id: randomUUID(), 33 | path, 34 | content, 35 | name, 36 | extension, 37 | ctime, 38 | mtime, 39 | vaultId, 40 | }); 41 | } 42 | } catch (error) { 43 | console.error(error); 44 | return; 45 | } 46 | } 47 | 48 | const vaultStmnt = orm.getAllNodesOnVault(); 49 | 50 | const vault = vaultStmnt.get({ 51 | id: vaultId, 52 | }); 53 | return vault; 54 | }; 55 | -------------------------------------------------------------------------------- /server/utils/generateToken.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import type { ReqUser } from '../types'; 3 | import { orm } from '../db/orm'; 4 | 5 | type TokenType = 'access' | 'refresh'; 6 | 7 | export const generateToken = async ( 8 | userId: ReqUser['userId'], 9 | username: ReqUser['username'], 10 | expiresIn: string, 11 | type: TokenType, 12 | ) => { 13 | const token = jwt.sign( 14 | { userId, username }, 15 | // JWT_REFRESH_TOKEN || JWT_ACCESS_TOKEN 16 | process.env[`JWT_${type.toUpperCase()}_SECRET`] as string, 17 | { 18 | expiresIn: expiresIn, 19 | }, 20 | ); 21 | 22 | // if refresh token, save it to the DB in user.refreshToken 23 | if (type === 'refresh') { 24 | const userStmnt = orm.updateUser(); 25 | userStmnt.run({ 26 | username, 27 | refreshToken: token, 28 | }); 29 | } 30 | 31 | return token; 32 | }; 33 | -------------------------------------------------------------------------------- /server/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import morgan from 'morgan'; 3 | 4 | const { combine, timestamp, prettyPrint } = winston.format; 5 | export const logger = winston.createLogger({ 6 | level: 'http', 7 | format: combine( 8 | timestamp({ 9 | format: () => 10 | new Date().toLocaleString('en-US', { 11 | timeZone: 'America/Chicago', 12 | }), 13 | }), 14 | prettyPrint({ 15 | colorize: true, 16 | }), 17 | ), 18 | transports: [new winston.transports.Console()], 19 | }); 20 | export const morganMiddleware = morgan( 21 | ':method :status :url - :remote-addr - :response-time ms', 22 | { 23 | stream: { 24 | write: (message) => { 25 | // TODO: colorize based on status code 26 | return logger.http(message); 27 | }, 28 | }, 29 | }, 30 | ); 31 | -------------------------------------------------------------------------------- /server/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import { config } from 'dotenv'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | environment: 'obsync', 8 | setupFiles: ['./__tests__/setupDb.ts'], 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | let 2 | unstable = import {}; 3 | in 4 | { pkgs ? import {} }: 5 | pkgs.mkShell { 6 | # nativeBuildInputs is usually what you want -- tools you need to run 7 | packages = [ 8 | pkgs.nodejs_22 9 | pkgs.openssl 10 | unstable.pnpm 11 | pkgs.sqlite 12 | pkgs.podman-compose 13 | pkgs.sqlitebrowser 14 | ]; 15 | } 16 | --------------------------------------------------------------------------------