├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── esbuild.config.mjs ├── img ├── explainer-img.png ├── menu_example.png ├── noteshare-example.png └── success_example.png ├── main.ts ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── NoteSharingService.ts ├── crypto │ ├── crypto.test.ts │ ├── crypto.ts │ └── encryption.ts ├── lib │ ├── anonUserId.test.ts │ ├── anonUserId.ts │ ├── cache │ │ ├── AbstractCache.ts │ │ ├── FsCache.ts │ │ └── LocalStorageCache.ts │ ├── obsidian-svelte │ │ ├── Icon.svelte │ │ ├── IconButton.svelte │ │ ├── index.ts │ │ └── useIcon.ts │ └── stores │ │ ├── ActiveCacheFile.ts │ │ ├── ActiveMdFile.ts │ │ └── CacheStore.ts ├── obsidian │ ├── Frontmatter.ts │ ├── PluginSettings.ts │ └── SettingsTab.ts ├── types │ └── index.d.ts └── ui │ ├── QuickShareSideView.svelte │ ├── QuickShareSideView.ts │ ├── SharedNoteSuccessComponent.svelte │ └── SharedNoteSuccessModal.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs ├── versions.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | OUTDIR="/Users/username/Documents/obsidian-test-vault/.obsidian/plugins/obsidian-svelte-template" 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Please use this bug report template to help reproduce and fix bugs in Noteshare.space! 4 | title: "[Bug]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | - Describe the steps you took to reach this bug. 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | - Provide a screenshot showing what is wrong. 22 | - If possible, share a note to noteshare.space and put the share link in this bug report so I can further debug the problem. 23 | 24 | **Environment (please complete the following information):** 25 | - OS: [e.g. Windows, MacOS, iOS, Android, ...] 26 | - Browser [e.g. chrome, safari] 27 | - Version [e.g. 22] 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature request]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | PLUGIN_NAME: obsidian-quickshare # Change this to match the id of your plugin. 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: "16.13" 21 | 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run test 27 | npm run build 28 | mkdir ${{ env.PLUGIN_NAME }} 29 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 30 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 31 | ls 32 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 33 | - name: Create Release 34 | id: create_release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | VERSION: ${{ github.ref }} 39 | with: 40 | tag_name: ${{ github.ref }} 41 | release_name: ${{ github.ref }} 42 | draft: false 43 | prerelease: false 44 | 45 | - name: Upload zip file 46 | id: upload-zip 47 | uses: actions/upload-release-asset@v1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | with: 51 | upload_url: ${{ steps.create_release.outputs.upload_url }} 52 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 53 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 54 | asset_content_type: application/zip 55 | 56 | - name: Upload main.js 57 | id: upload-main 58 | uses: actions/upload-release-asset@v1 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | with: 62 | upload_url: ${{ steps.create_release.outputs.upload_url }} 63 | asset_path: ./main.js 64 | asset_name: main.js 65 | asset_content_type: text/javascript 66 | 67 | - name: Upload manifest.json 68 | id: upload-manifest 69 | uses: actions/upload-release-asset@v1 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | with: 73 | upload_url: ${{ steps.create_release.outputs.upload_url }} 74 | asset_path: ./manifest.json 75 | asset_name: manifest.json 76 | asset_content_type: application/json 77 | 78 | - name: Upload styles.css 79 | id: upload-css 80 | uses: actions/upload-release-asset@v1 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 83 | with: 84 | upload_url: ${{ steps.create_release.outputs.upload_url }} 85 | asset_path: ./styles.css 86 | asset_name: styles.css 87 | asset_content_type: text/css 88 | -------------------------------------------------------------------------------- /.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 main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.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 23 | 24 | # Exclude coverage 25 | coverage 26 | 27 | .env 28 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.3.1] 4 | 5 | - fix: Make sidebar pane non-navigable (Thanks [kometenstaub](https://github.com/mcndt/obsidian-quickshare/issues/27)) 6 | 7 | ## [1.3.0] 8 | 9 | - feat: You can now unshare notes before they expire from the server. 10 | - feat: Quickshare now has a sidebar panel to visually see your recently shared notes. 11 | 12 | ## [1.2.0] 13 | 14 | - feat: the plugin now shares the note's name as title (like Obsidian's "show inline title" option in the appearance settings). This behavior can be disabled in the plugin's settings. 15 | 16 | ## [1.1.1] 17 | 18 | - improvement: frontmatter URL is now wrapped in quotation marks to escape YAML formatting. 19 | - improvement: frontmatter can be turned off in the settings 20 | - improvement: frontmatter date format can be set in settings 21 | 22 | ## [1.1.0] 23 | 24 | - feat: After sharing a note, the URL is now stored in the note's frontmatter, along with the time it was shared. 25 | 26 | ## [1.0.2] 27 | 28 | - security: upgraded encryption schema to use a GCM cipher. 29 | - security: quickshare now generates a random initialization vector instead of using a zero-vector to prevent [theoretical known-plaintext attacks](https://github.com/mcndt/obsidian-quickshare/issues/21). 30 | 31 | ## [1.0.1] 32 | 33 | - security: changed key generation implementation to mitigate content verification attack (see https://github.com/mcndt/obsidian-quickshare/issues/20 to learn more). 34 | 35 | ## [1.0.0] 36 | 37 | Obsidian QuickShare and Noteshare.space are now out of beta 🚀 You can now find the plugin 38 | in the Obsidian community plugin marketplace (see [instructions](https://noteshare.space/install)). 39 | Check out the roadmap for upcoming features [here](https://noteshare.space/roadmap). 40 | 41 | This release is further identical to version 0.4.1. 42 | 43 | ## [0.4.0] 44 | 45 | - feat: New notes are now encrypted using the SubtleCrypto web standard for better security and performance. 46 | 47 | ## [0.3.0] 48 | 49 | - The plugin now sends an anonymous user id and your plugin version to server. This will be used to track broad usage service utility trends (e.g. active users/week) and apply fair use limits per user. The user id is generated using a random number generator and reveals no identifiable information about the user. 50 | 51 | ## [0.2.0] 52 | 53 | - Rebranded the plugin to QuickShare. I believe this better encapsulates what the plugin does, and is easier to say. 54 | 55 | ## [0.1.1] 56 | 57 | - bug: 🐛 Fix the server URL not resetting to `https://noteshare.space` when enabling the "Use Noteshare.space" option in the plugin settings. 58 | 59 | ## [0.1.0] 60 | 61 | ### First version! 62 | 63 | - Added command: "Create share link" 64 | - Added item to Options menu on Markdown panes: "Create share link" 65 | - Default note storage provider is set to [Noteshare.space](https://noteshare.space) 66 | - Storage provider can be changed for users who want to host their own instance 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Maxime Cannoodt 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub tag (Latest by date)](https://img.shields.io/github/v/tag/mcndt/obsidian-quickshare)](https://github.com/mcndt/obsidian-quickshare/releases) ![GitHub all releases](https://img.shields.io/github/downloads/mcndt/obsidian-quickshare/total) 2 | 3 | # Obsidian QuickShare - Securely share your Obsidian notes with one click 4 | 5 | Host Obsidian notes over the internet using end-to-end encryption. Requires zero configuration and no account. 6 | 7 | I built this service mainly to use for myself, as I got sick of finding third-party services to quickly share some Markdown notes I wrote in Obsidian. Because I believe that others might find this useful too, I decided to release it as a public service. 8 | 9 | ## How it works 10 | 11 | Your notes are stored securely using strong AES-256-CBC encryption. The decryption key is never sent to the server, so not even the server can open your notes. 12 | 13 | By default, notes are stored on my companion project, [Noteshare.space](https://noteshare.space/). It is a simple storage service that caches your encrypted notes for 31 days and hosts a web application for viewing them. Both the server and frontend are [open source](https://github.com/mcndt/noteshare.space) under the MIT license. Users are free to host an instance of the storage service at their own domain. 14 | 15 | ![Explainer](img/explainer-img.png) 16 | ## Functionality 17 | 18 | - AES-256-CBC encryption with single-use encryption keys 19 | - Requires no account or external API keys 20 | - Use [Noteshare.space](https://noteshare.space) to share notes for free, or [host your own instance](https://github.com/mcndt/noteshare.space#deployment) to gain full control. 21 | 22 | ## Installing 23 | 24 | Both the plugin and Noteshare.space are currently in beta. To beta test, you can install the plugin using BRAT (see [BRAT > Adding a beta plugin](https://github.com/TfTHacker/obsidian42-brat#adding-a-beta-plugin) for further instructions). 25 | 26 | ## Feedback 27 | 28 | The preferred way to report bugs or request new features for the web app or the Obsidian plugin is via the [GitHub issues page](https://github.com/mcndt/obsidian-quickshare/issues/new/choose). 29 | 30 | If you want a more interactive way to discuss bugs or features, you can join the [Discord server](https://discord.gg/y3HqyGeABK). 31 | 32 | ## Support 33 | If you like this plugin and want to support me, you can do so via [Buy me a Coffee](https://www.buymeacoffee.com/mcndt): 34 | 35 | 36 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | import eslint from "esbuild-plugin-eslint"; 6 | import esbuildSvelte from "esbuild-svelte"; 7 | import sveltePreprocess from "svelte-preprocess"; 8 | 9 | import { config } from "dotenv"; 10 | import { join } from "path"; 11 | import { copyFileSync, writeFileSync } from "fs"; 12 | 13 | config(); 14 | 15 | const banner = `/* 16 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 17 | if you want to view the source, please visit the github repository of this plugin 18 | */ 19 | `; 20 | 21 | const prod = process.argv[2] === "production"; 22 | const dir = prod ? "./" : process.env.OUTDIR; 23 | const outfile = join(dir, "main.js"); 24 | 25 | if (!prod) { 26 | copyFileSync("./manifest.json", join(dir, "manifest.json")); 27 | writeFileSync( 28 | join(dir, ".hotreload"), 29 | "This file is used to enable hot reloading using the Hot Reload plugin. See https://github.com/pjeby/hot-reload for more information." 30 | ); 31 | } 32 | 33 | esbuild 34 | .build({ 35 | banner: { 36 | js: banner, 37 | }, 38 | entryPoints: ["main.ts"], 39 | bundle: true, 40 | plugins: [ 41 | esbuildSvelte({ 42 | compilerOptions: { css: true }, 43 | preprocess: sveltePreprocess(), 44 | }), 45 | eslint(), 46 | ], 47 | external: [ 48 | "obsidian", 49 | "electron", 50 | "@codemirror/autocomplete", 51 | "@codemirror/closebrackets", 52 | "@codemirror/collab", 53 | "@codemirror/commands", 54 | "@codemirror/comment", 55 | "@codemirror/fold", 56 | "@codemirror/gutter", 57 | "@codemirror/highlight", 58 | "@codemirror/history", 59 | "@codemirror/language", 60 | "@codemirror/lint", 61 | "@codemirror/matchbrackets", 62 | "@codemirror/panel", 63 | "@codemirror/rangeset", 64 | "@codemirror/rectangular-selection", 65 | "@codemirror/search", 66 | "@codemirror/state", 67 | "@codemirror/stream-parser", 68 | "@codemirror/text", 69 | "@codemirror/tooltip", 70 | "@codemirror/view", 71 | ...builtins, 72 | ], 73 | format: "cjs", 74 | watch: !prod, 75 | target: "es2016", 76 | logLevel: "info", 77 | sourcemap: prod ? false : "inline", 78 | treeShaking: true, 79 | outfile: outfile, 80 | }) 81 | .catch(() => process.exit(1)); 82 | -------------------------------------------------------------------------------- /img/explainer-img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcndt/obsidian-quickshare/d1966fb6c8f5888405c67b06fb64c9df98d4afe2/img/explainer-img.png -------------------------------------------------------------------------------- /img/menu_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcndt/obsidian-quickshare/d1966fb6c8f5888405c67b06fb64c9df98d4afe2/img/menu_example.png -------------------------------------------------------------------------------- /img/noteshare-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcndt/obsidian-quickshare/d1966fb6c8f5888405c67b06fb64c9df98d4afe2/img/noteshare-example.png -------------------------------------------------------------------------------- /img/success_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcndt/obsidian-quickshare/d1966fb6c8f5888405c67b06fb64c9df98d4afe2/img/success_example.png -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MarkdownView, 3 | Menu, 4 | Notice, 5 | Plugin, 6 | TAbstractFile, 7 | TFile, 8 | WorkspaceLeaf, 9 | } from "obsidian"; 10 | import { NoteSharingService } from "src/NoteSharingService"; 11 | import { DEFAULT_SETTINGS } from "src/obsidian/PluginSettings"; 12 | import SettingsTab from "src/obsidian/SettingsTab"; 13 | import { SharedNoteSuccessModal } from "src/ui/SharedNoteSuccessModal"; 14 | import type { EventRef } from "obsidian"; 15 | import type { PluginSettings } from "src/obsidian/PluginSettings"; 16 | import { useFrontmatterHelper } from "src/obsidian/Frontmatter"; 17 | import moment from "moment"; 18 | import type { QuickShareCache } from "src/lib/cache/AbstractCache"; 19 | import { LocalStorageCache } from "src/lib/cache/LocalStorageCache"; 20 | import { FsCache } from "src/lib/cache/FsCache"; 21 | import { QuickShareSideView } from "src/ui/QuickShareSideView"; 22 | import { writable } from "svelte/store"; 23 | import { setActiveMdFile } from "src/lib/stores/ActiveMdFile"; 24 | 25 | const { subscribe, set: setPluginStore } = writable(null); 26 | 27 | export const PluginStore = { subscribe }; 28 | 29 | export default class NoteSharingPlugin extends Plugin { 30 | public settings: PluginSettings; 31 | private noteSharingService: NoteSharingService; 32 | private cache: QuickShareCache; 33 | 34 | private fileMenuEvent: EventRef; 35 | 36 | async onload() { 37 | setPluginStore(this); 38 | await this.loadSettings(); 39 | 40 | this.cache = this.settings.useFsCache 41 | ? await new FsCache(this.app).init() 42 | : await new LocalStorageCache(this.app).init(); 43 | 44 | this.noteSharingService = new NoteSharingService( 45 | this.settings.serverUrl, 46 | this.settings.anonymousUserId, 47 | this.manifest.version 48 | ); 49 | 50 | // Init settings tab 51 | this.addSettingTab(new SettingsTab(this.app, this)); 52 | 53 | // Add note sharing command 54 | this.addCommands(); 55 | 56 | // Add event listeners 57 | this.fileMenuEvent = this.app.workspace.on( 58 | "file-menu", 59 | (menu, file, source) => this.onMenuOpenCallback(menu, file, source) 60 | ); 61 | this.registerEvent(this.fileMenuEvent); 62 | 63 | this.registerEvent( 64 | this.app.vault.on("rename", (file, oldPath) => { 65 | if (!this.cache.has(oldPath)) { 66 | return; 67 | } 68 | this.cache.rename(oldPath, file.path); 69 | console.log("renamed", file.path); 70 | }) 71 | ); 72 | 73 | this.registerEvent( 74 | this.app.vault.on("delete", (file) => { 75 | if (!this.cache.has(file.path)) { 76 | return; 77 | } 78 | this.cache.set(file.path, (data) => ({ 79 | ...data, 80 | deleted_from_vault: true, 81 | })); 82 | console.log("deleted", file.path); 83 | }) 84 | ); 85 | 86 | this.registerEvent( 87 | this.app.workspace.on("active-leaf-change", (leaf) => { 88 | if (leaf.view instanceof MarkdownView) { 89 | setActiveMdFile(leaf.view.file); 90 | } 91 | }) 92 | ); 93 | 94 | // Register the sidebar view 95 | this.registerView( 96 | QuickShareSideView.viewType, 97 | (leaf: WorkspaceLeaf) => new QuickShareSideView(leaf) 98 | ); 99 | 100 | // Add the view to the right sidebar 101 | this.app.workspace.onLayoutReady(this.initLeaf.bind(this)); 102 | } 103 | 104 | async initLeaf() { 105 | if ( 106 | this.app.workspace.getLeavesOfType(QuickShareSideView.viewType) 107 | .length 108 | ) { 109 | return; 110 | } 111 | await this.app.workspace.getRightLeaf(false).setViewState({ 112 | type: QuickShareSideView.viewType, 113 | active: true, 114 | }); 115 | } 116 | 117 | onunload() {} 118 | 119 | async loadSettings() { 120 | this.settings = Object.assign( 121 | {}, 122 | DEFAULT_SETTINGS, 123 | await this.loadData() 124 | ); 125 | await this.saveSettings(); 126 | } 127 | 128 | async saveSettings() { 129 | await this.saveData(this.settings); 130 | if (this.noteSharingService) { 131 | this.noteSharingService.serverUrl = this.settings.serverUrl; 132 | } 133 | } 134 | 135 | addCommands() { 136 | this.addCommand({ 137 | id: "obsidian-quickshare-share-note", 138 | name: "Create share link", 139 | checkCallback: (checking: boolean) => { 140 | // Only works on Markdown views 141 | const activeView = 142 | this.app.workspace.getActiveViewOfType(MarkdownView); 143 | if (!activeView) return false; 144 | if (checking) return true; 145 | this.shareNote(activeView.file); 146 | }, 147 | }); 148 | 149 | this.addCommand({ 150 | id: "obsidian-quickshare-delete-note", 151 | name: "Unshare note", 152 | checkCallback: (checking: boolean) => { 153 | // Only works on Markdown views 154 | const activeView = 155 | this.app.workspace.getActiveViewOfType(MarkdownView); 156 | if (!activeView) return false; 157 | 158 | if ( 159 | (checking && !this.cache.has(activeView.file.path)) || 160 | this.cache.get(activeView.file.path).deleted_from_server 161 | ) { 162 | return false; 163 | } 164 | if (checking) { 165 | return true; 166 | } 167 | this.deleteNote(activeView.file.path); 168 | }, 169 | }); 170 | } 171 | 172 | // https://github.dev/platers/obsidian-linter/blob/c30ceb17dcf2c003ca97862d94cbb0fd47b83d52/src/main.ts#L139-L149 173 | onMenuOpenCallback(menu: Menu, file: TAbstractFile, source: string) { 174 | if (file instanceof TFile && file.extension === "md") { 175 | menu.addItem((item) => { 176 | item.setIcon("paper-plane-glyph"); 177 | item.setTitle("Create share link"); 178 | item.onClick(async (evt) => { 179 | this.shareNote(file); 180 | }); 181 | }); 182 | } 183 | } 184 | 185 | async shareNote(file: TFile) { 186 | const { setFrontmatterKeys } = useFrontmatterHelper(this.app); 187 | 188 | const body = await this.app.vault.read(file); 189 | const title = this.settings.shareFilenameAsTitle 190 | ? file.basename 191 | : undefined; 192 | 193 | this.noteSharingService 194 | .shareNote(body, { title }) 195 | .then((res) => { 196 | if (this.settings.useFrontmatter) { 197 | const datetime = moment().format( 198 | this.settings.frontmatterDateFormat || 199 | DEFAULT_SETTINGS.frontmatterDateFormat 200 | ); 201 | setFrontmatterKeys(file, { 202 | url: `"${res.view_url}"`, 203 | datetime: datetime, 204 | }); 205 | } 206 | 207 | // NOTE: this is an async call, but we don't need to wait for it 208 | this.cache.set(file.path, { 209 | shared_datetime: moment().toISOString(), 210 | updated_datetime: null, 211 | expire_datetime: res.expire_time.toISOString(), 212 | view_url: res.view_url, 213 | secret_token: res.secret_token, 214 | note_id: res.note_id, 215 | basename: file.basename, 216 | }); 217 | 218 | new SharedNoteSuccessModal( 219 | this, 220 | res.view_url, 221 | res.expire_time 222 | ).open(); 223 | }) 224 | .catch(this.handleSharingError); 225 | } 226 | 227 | async deleteNote(fileId: string) { 228 | const { setFrontmatterKeys } = useFrontmatterHelper(this.app); 229 | 230 | const cacheData = this.cache.get(fileId); 231 | 232 | if (!cacheData) { 233 | return; 234 | } 235 | 236 | this.noteSharingService 237 | .deleteNote(cacheData.note_id, cacheData.secret_token) 238 | .then(() => { 239 | this.cache.set(fileId, (data) => ({ 240 | ...data, 241 | deleted_from_server: true, 242 | })); 243 | new Notice(`Unshared note: "${cacheData.basename}"`, 7500); 244 | console.info("Unshared note: ", fileId); 245 | 246 | const _file = this.app.vault 247 | .getMarkdownFiles() 248 | .find((f) => f.path === fileId); 249 | 250 | if (!_file) { 251 | return; 252 | } 253 | 254 | setFrontmatterKeys(_file, { 255 | url: `"Removed"`, 256 | datetime: `"N/A"`, 257 | }); 258 | }) 259 | .catch(this.handleSharingError); 260 | } 261 | 262 | public set $cache(cache: QuickShareCache) { 263 | this.cache = cache; 264 | } 265 | 266 | public get $cache() { 267 | return this.cache; 268 | } 269 | 270 | private handleSharingError(err: Error) { 271 | console.error(err); 272 | new Notice(err.message, 7500); 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-quickshare", 3 | "name": "QuickShare", 4 | "version": "1.3.1", 5 | "minAppVersion": "0.13.25", 6 | "description": "Securely share your Obsidian notes with one click. Notes are end-to-end encrypted. No API keys or configuration required.", 7 | "author": "Maxime Cannoodt (@mcndt)", 8 | "authorUrl": "https://mcndt.dev", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-note-sharing", 3 | "version": "1.3.1", 4 | "description": "Obsidian plugin for encrypting and uploading notes to noteshare.space.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "test-watch": "vitest", 10 | "test": "vitest run", 11 | "coverage": "vitest run --coverage", 12 | "version": "node version-bump.mjs && git add manifest.json versions.json" 13 | }, 14 | "keywords": [], 15 | "author": "Maxime Cannoodt (@mcndt)", 16 | "license": "MIT", 17 | "devDependencies": { 18 | "@tsconfig/svelte": "^3.0.0", 19 | "@types/crypto-js": "^4.1.1", 20 | "@types/node": "^16.11.6", 21 | "@typescript-eslint/eslint-plugin": "^5.2.0", 22 | "@typescript-eslint/parser": "^5.2.0", 23 | "builtin-modules": "^3.2.0", 24 | "c8": "^7.11.3", 25 | "dotenv": "^16.0.3", 26 | "esbuild": "^0.15.14", 27 | "esbuild-plugin-eslint": "^0.1.1", 28 | "esbuild-svelte": "^0.7.1", 29 | "happy-dom": "^6.0.4", 30 | "obsidian": "latest", 31 | "sass": "^1.56.1", 32 | "svelte": "^3.49.0", 33 | "svelte-preprocess": "^4.10.7", 34 | "tslib": "2.3.1", 35 | "typescript": "~4.5.0", 36 | "vitest": "^0.15.1" 37 | }, 38 | "dependencies": { 39 | "crc": "^4.1.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/NoteSharingService.ts: -------------------------------------------------------------------------------- 1 | import moment, { type Moment } from "moment"; 2 | import { requestUrl } from "obsidian"; 3 | import { encryptString } from "./crypto/encryption"; 4 | 5 | type ShareNoteOptions = { 6 | title?: string; 7 | }; 8 | 9 | type JsonPayload = { 10 | body: string; 11 | title?: string; 12 | metadata?: Record; 13 | }; 14 | 15 | type Response = { 16 | view_url: string; 17 | expire_time: Moment; 18 | secret_token: string; 19 | note_id: string; 20 | }; 21 | 22 | export class NoteSharingService { 23 | private _url: string; 24 | private _userId: string; 25 | private _pluginVersion: string; 26 | 27 | constructor(serverUrl: string, userId: string, pluginVersion: string) { 28 | this.serverUrl = serverUrl; 29 | this._userId = userId; 30 | this._pluginVersion = pluginVersion; 31 | } 32 | 33 | /** 34 | * @param body Markdown file to share. 35 | * @returns link to shared note with attached decryption key. 36 | */ 37 | public async shareNote( 38 | body: string, 39 | options?: ShareNoteOptions 40 | ): Promise { 41 | body = this.sanitizeNote(body); 42 | 43 | const jsonPayload: JsonPayload = { 44 | body: body, 45 | title: options?.title, 46 | }; 47 | 48 | const stringPayload = JSON.stringify(jsonPayload); 49 | 50 | const { ciphertext, iv, key } = await encryptString(stringPayload); 51 | const res = await this.postNote(ciphertext, iv); 52 | res.view_url += `#${key}`; 53 | console.log(`Note shared: ${res.view_url}`); 54 | return res; 55 | } 56 | 57 | public async deleteNote( 58 | noteId: string, 59 | secretToken: string 60 | ): Promise { 61 | await requestUrl({ 62 | url: `${this._url}/api/note/${noteId}`, 63 | method: "DELETE", 64 | contentType: "application/json", 65 | body: JSON.stringify({ 66 | user_id: this._userId, 67 | secret_token: secretToken, 68 | }), 69 | }); 70 | } 71 | 72 | private async postNote(ciphertext: string, iv: string): Promise { 73 | const res = await requestUrl({ 74 | url: `${this._url}/api/note`, 75 | method: "POST", 76 | contentType: "application/json", 77 | body: JSON.stringify({ 78 | ciphertext: ciphertext, 79 | iv: iv, 80 | user_id: this._userId, 81 | plugin_version: this._pluginVersion, 82 | crypto_version: "v3", 83 | }), 84 | }); 85 | 86 | if (res.status == 200 && res.json != null) { 87 | const returnValue = res.json; 88 | returnValue.expire_time = moment(returnValue.expire_time); 89 | return returnValue; 90 | } 91 | throw Error( 92 | `Error uploading encrypted note (${res.status}): ${res.text}` 93 | ); 94 | } 95 | 96 | private sanitizeNote(mdText: string): string { 97 | mdText = mdText.trim(); 98 | const match = mdText.match( 99 | /^(?:---\s*\n)(?:(?:.*?\n)*?)(?:---)((?:.|\n|\r)*)/ 100 | ); 101 | if (match) { 102 | mdText = match[1].trim(); 103 | } 104 | return mdText; 105 | } 106 | 107 | public set serverUrl(newUrl: string) { 108 | newUrl = newUrl.replace(/([^:]\/)\/+/g, "$1"); 109 | if (newUrl[newUrl.length - 1] == "/") { 110 | newUrl = newUrl.substring(0, newUrl.length - 1); 111 | } 112 | this._url = newUrl; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/crypto/crypto.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | import { 3 | encryptString, 4 | decryptString, 5 | generateKey, 6 | masterKeyToString, 7 | base64ToArrayBuffer, 8 | generateRandomKey, 9 | } from "./crypto"; 10 | 11 | import { webcrypto } from "crypto"; 12 | 13 | vi.stubGlobal("crypto", { 14 | // @ts-ignore - bad typing on webcrypto 15 | getRandomValues: webcrypto.getRandomValues, 16 | subtle: webcrypto.subtle, 17 | }); 18 | 19 | const testData = "This is the test data."; 20 | 21 | describe("Encryption suite", () => { 22 | it("should convert a key to and from base64 correctly", async () => { 23 | const secret = await generateKey(testData); 24 | const secretString = masterKeyToString(secret); 25 | const secret2 = base64ToArrayBuffer(secretString); 26 | 27 | expect(secret2).toEqual(secret); 28 | }); 29 | 30 | it("should generate 256-bit keys", async () => { 31 | const key = await generateKey(testData); 32 | expect(key.byteLength).toEqual(32); 33 | expect(masterKeyToString(key)).toHaveLength(44); 34 | 35 | const key2 = await generateRandomKey(); 36 | expect(key2.byteLength).toEqual(32); 37 | expect(masterKeyToString(key2)).toHaveLength(44); 38 | }); 39 | 40 | it("should generate deterministic 256-bit keys from seed material", async () => { 41 | const key1 = await generateKey(testData); 42 | const key2 = await generateKey(testData); 43 | expect(key1).toEqual(key2); 44 | }); 45 | 46 | it("should generate random 256-bit keys", async () => { 47 | const key = await generateRandomKey(); 48 | const key2 = await generateRandomKey(); 49 | expect(key).not.toEqual(key2); 50 | }); 51 | 52 | it("should encrypt", async () => { 53 | const key = await generateKey(testData); 54 | const encryptedData = await encryptString(testData, key); 55 | expect(encryptedData).toHaveProperty("ciphertext"); 56 | expect(encryptedData).toHaveProperty("iv"); 57 | }); 58 | 59 | it("should decrypt encrypted data with the correct key", async () => { 60 | const key = await generateKey(testData); 61 | const encryptedData = await encryptString(testData, key); 62 | const data = await decryptString(encryptedData, key); 63 | expect(data).toEqual(testData); 64 | }); 65 | 66 | it("should decrypt encrypted data with the correct deserialized key", async () => { 67 | const key = await generateKey(testData); 68 | const encryptedData = await encryptString(testData, key); 69 | const keyString = masterKeyToString(key); 70 | 71 | const key2 = base64ToArrayBuffer(keyString); 72 | const data = await decryptString(encryptedData, key2); 73 | expect(data).toEqual(testData); 74 | }); 75 | 76 | it("should fail decrypting with wrong key", async () => { 77 | const key = await generateKey(testData); 78 | const ciphertext = await encryptString(testData, key); 79 | const tempKey = await generateKey("wrong key"); 80 | await expect(decryptString(ciphertext, tempKey)).rejects.toThrowError( 81 | /Cannot decrypt ciphertext with this key./g 82 | ); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/crypto/crypto.ts: -------------------------------------------------------------------------------- 1 | interface EncryptedData { 2 | ciphertext: string; 3 | iv: string; 4 | /** @deprecated Please use GCM encryption instead. */ 5 | hmac?: string; 6 | } 7 | 8 | /** 9 | * Generates a 256-bit key from a 10 | * Note: I don't add a salt because the key will be derived from a different 11 | * passphrase for every shared note anyways.. 12 | * @param seed passphrase-like data to generate the key from. 13 | */ 14 | export async function generateKey(seed: string): Promise { 15 | const _seed = new TextEncoder().encode(seed); 16 | return _generateKey(_seed); 17 | } 18 | 19 | /** 20 | * Generates a random 256-bit key using crypto.getRandomValues. 21 | */ 22 | export async function generateRandomKey(): Promise { 23 | const seed = window.crypto.getRandomValues(new Uint8Array(64)); 24 | return _generateKey(seed); 25 | } 26 | 27 | async function _generateKey(seed: ArrayBuffer) { 28 | const keyMaterial = await window.crypto.subtle.importKey( 29 | "raw", 30 | seed, 31 | { name: "PBKDF2" }, 32 | false, 33 | ["deriveBits"] 34 | ); 35 | 36 | const masterKey = await window.crypto.subtle.deriveBits( 37 | { 38 | name: "PBKDF2", 39 | salt: new Uint8Array(16), 40 | iterations: 100000, 41 | hash: "SHA-256", 42 | }, 43 | keyMaterial, 44 | 256 45 | ); 46 | 47 | return new Uint8Array(masterKey); 48 | } 49 | 50 | export function masterKeyToString(masterKey: ArrayBuffer): string { 51 | return arrayBufferToBase64(masterKey); 52 | } 53 | 54 | export async function encryptString( 55 | md: string, 56 | secret: ArrayBuffer 57 | ): Promise { 58 | const plaintext = new TextEncoder().encode(md); 59 | 60 | const iv = window.crypto.getRandomValues(new Uint8Array(16)); 61 | 62 | const buf_ciphertext: ArrayBuffer = await window.crypto.subtle.encrypt( 63 | { name: "AES-GCM", iv: iv }, 64 | await _getAesGcmKey(secret), 65 | plaintext 66 | ); 67 | 68 | const ciphertext = arrayBufferToBase64(buf_ciphertext); 69 | 70 | return { ciphertext, iv: arrayBufferToBase64(iv) }; 71 | } 72 | 73 | export async function decryptString( 74 | { ciphertext, iv }: EncryptedData, 75 | secret: ArrayBuffer 76 | ): Promise { 77 | const ciphertext_buf = base64ToArrayBuffer(ciphertext); 78 | const iv_buf = base64ToArrayBuffer(iv); 79 | 80 | const md = await window.crypto.subtle 81 | .decrypt( 82 | { name: "AES-GCM", iv: iv_buf }, 83 | await _getAesGcmKey(secret), 84 | ciphertext_buf 85 | ) 86 | .catch((e) => { 87 | throw new Error(`Cannot decrypt ciphertext with this key.`); 88 | }); 89 | return new TextDecoder().decode(md); 90 | } 91 | 92 | export function arrayBufferToBase64(buffer: ArrayBuffer): string { 93 | return window.btoa(String.fromCharCode(...new Uint8Array(buffer))); 94 | } 95 | 96 | export function base64ToArrayBuffer(base64: string): ArrayBuffer { 97 | return Uint8Array.from(window.atob(base64), (c) => c.charCodeAt(0)); 98 | } 99 | 100 | function _getAesGcmKey(secret: ArrayBuffer): Promise { 101 | return window.crypto.subtle.importKey( 102 | "raw", 103 | secret, 104 | { name: "AES-GCM", length: 256 }, 105 | false, 106 | ["encrypt", "decrypt"] 107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /src/crypto/encryption.ts: -------------------------------------------------------------------------------- 1 | import { 2 | encryptString as _encryptString, 3 | masterKeyToString, 4 | generateRandomKey, 5 | } from "./crypto"; 6 | 7 | export interface EncryptedString { 8 | ciphertext: string; 9 | key: string; 10 | iv: string; 11 | /** @deprecated Please use GCM with IV instead. */ 12 | hmac?: string; 13 | } 14 | 15 | export async function encryptString( 16 | plaintext: string 17 | ): Promise { 18 | const key = await generateRandomKey(); 19 | const { ciphertext, iv } = await _encryptString(plaintext, key); 20 | return { ciphertext, iv, key: masterKeyToString(key).slice(0, 43) }; 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/anonUserId.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { checkId, generateId } from "./anonUserId"; 3 | 4 | const VALID_ID = "f06536e7df6857fc"; 5 | const INVALID_ID_WRONG_CRC = "f06536e7df6857fd"; 6 | const INVALID_ID_WRONG_LENGTH = "0"; 7 | 8 | describe("Generating user IDs", () => { 9 | it("should generate different IDs each time", () => { 10 | for (let i = 0; i < 100; i++) { 11 | const id1 = generateId(); 12 | const id2 = generateId(); 13 | expect(id1).not.toEqual(id2); 14 | } 15 | }); 16 | 17 | it("should generate a userId with length 16 and valid CRC", () => { 18 | for (let i = 0; i < 100; i++) { 19 | const id = generateId(); 20 | expect(id).toHaveLength(16); 21 | expect(checkId(id)).toBe(true); 22 | } 23 | }); 24 | }); 25 | 26 | describe("checking user IDs", () => { 27 | it("should fail userIds other than 16 chars", () => { 28 | expect(checkId(INVALID_ID_WRONG_LENGTH)).toBe(false); 29 | }); 30 | 31 | it("should fail userIds with invalid CRC", () => { 32 | expect(checkId(INVALID_ID_WRONG_CRC)).toBe(false); 33 | }); 34 | 35 | it("should pass userIds with valid length and CRC", () => { 36 | expect(checkId(VALID_ID)).toBe(true); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/lib/anonUserId.ts: -------------------------------------------------------------------------------- 1 | import crc from "crc/calculators/crc16"; 2 | 3 | /** 4 | * @returns {string} a 16 character base16 string with 12 random characters and 4 CRC characters 5 | */ 6 | export function generateId() { 7 | // Generate a random 64-bit number and convert to base16 string 8 | const random = Math.floor(Math.random() * 2 ** 64).toString(16); 9 | 10 | // truncate the string to 12 characters and pad if necessary 11 | const truncated = random.slice(0, 12).padStart(12, "0"); 12 | 13 | // compute the CRC of the random number 14 | 15 | // create int8array from "truncated" 16 | const buffer = new TextEncoder().encode(truncated); 17 | 18 | const checksum = crc(buffer).toString(16).padStart(4, "0"); 19 | 20 | return truncated + checksum; 21 | } 22 | 23 | /** 24 | * @param id {string} a 16 character base16 string with 12 random characters and 4 CRC characters 25 | * @returns {boolean} true if the id is valid, false otherwise 26 | */ 27 | export function checkId(id: string): boolean { 28 | // check length 29 | if (id.length !== 16) { 30 | return false; 31 | } 32 | // extract the random number and the checksum 33 | const random = id.slice(0, 12); 34 | const checksum = id.slice(12, 16); 35 | const buffer = new TextEncoder().encode(random); 36 | 37 | // compute the CRC of the random number 38 | const computedChecksum = crc(buffer).toString(16).padStart(4, "0"); 39 | 40 | // compare the computed checksum with the one in the id 41 | return computedChecksum === checksum; 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/cache/AbstractCache.ts: -------------------------------------------------------------------------------- 1 | import { updateReactiveCache } from "../stores/CacheStore"; 2 | 3 | export type QuickShareData = { 4 | shared_datetime: string; 5 | updated_datetime: string; 6 | expire_datetime: string; 7 | view_url: string; 8 | secret_token: string; 9 | deleted_from_vault?: boolean; 10 | deleted_from_server?: boolean; 11 | note_id: string; 12 | basename: string; 13 | }; 14 | 15 | type FileId = string; 16 | type Setter = (data: QuickShareData) => QuickShareData; 17 | 18 | export interface QuickShareCache { 19 | get: (fileId: FileId) => QuickShareData | undefined; 20 | set: (fileId: FileId, data: QuickShareData | Setter) => Promise; 21 | rename: (oldFileId: FileId, newFileId: FileId) => Promise; 22 | has: (fileId: FileId) => boolean; 23 | list: () => Promise; 24 | copy: (cache: QuickShareCache) => Promise; 25 | 26 | $getCache: () => Promise>; 27 | $deleteAllData: () => Promise; 28 | } 29 | 30 | export type CacheObject = Record; 31 | 32 | export type QuickShareDataList = (QuickShareData & { fileId: string })[]; 33 | 34 | export abstract class AbstractCache implements QuickShareCache { 35 | /** Get the QuickShareData for file with id. */ 36 | public get(fileId: FileId): QuickShareData | undefined { 37 | const cache = this._getCache(); 38 | return cache[fileId]; 39 | } 40 | 41 | /** Set the QuickShareData for file with id. */ 42 | public async set( 43 | fileId: FileId, 44 | data: QuickShareData | Setter 45 | ): Promise { 46 | const cache = await this._getCache(); 47 | if (typeof data === "function") { 48 | if (cache[fileId] === undefined) { 49 | throw new Error("File not found in cache."); 50 | } 51 | cache[fileId] = data(cache[fileId]); 52 | } else { 53 | cache[fileId] = data; 54 | } 55 | this.writeCache(cache); 56 | } 57 | 58 | /** Check if file with id is in cache. */ 59 | public has(fileId: FileId): boolean { 60 | const cache = this._getCache(); 61 | return cache[fileId] !== undefined; 62 | } 63 | 64 | /** Move the cache data to a new key and delete the old key. */ 65 | public async rename( 66 | oldFileId: FileId, 67 | newFileId: string, 68 | newBasename?: string 69 | ): Promise { 70 | const cache = this._getCache(); 71 | cache[newFileId] = cache[oldFileId]; 72 | delete cache[oldFileId]; 73 | if (newBasename) { 74 | cache[newFileId].basename = newBasename; 75 | } 76 | return this.writeCache(cache); 77 | } 78 | 79 | /** Get a list of QuickShareData for this vault. */ 80 | public async list(): Promise { 81 | const cache = this._getCache(); 82 | return Object.entries(cache).map(([fileId, data]) => ({ 83 | fileId, 84 | ...data, 85 | })); 86 | } 87 | 88 | /** Copies the contents of the passed cache to this cache. */ 89 | public async copy(cache: QuickShareCache): Promise { 90 | const data = await cache.$getCache(); 91 | this.writeCache(data); 92 | } 93 | 94 | public async $getCache(): Promise> { 95 | return this._getCache(); 96 | } 97 | 98 | private async writeCache(object: CacheObject): Promise { 99 | await this._writeCache(object); 100 | updateReactiveCache(await this.list()); 101 | } 102 | 103 | public async init(): Promise { 104 | const cache = await this._init(); 105 | updateReactiveCache(await this.list()); 106 | return cache; 107 | } 108 | 109 | public abstract $deleteAllData(): Promise; 110 | 111 | protected abstract _init(): Promise; 112 | 113 | protected abstract _getCache(): CacheObject; 114 | 115 | protected abstract _writeCache(object: CacheObject): Promise; 116 | } 117 | -------------------------------------------------------------------------------- /src/lib/cache/FsCache.ts: -------------------------------------------------------------------------------- 1 | import type { App } from "obsidian"; 2 | import { 3 | AbstractCache, 4 | type CacheObject, 5 | type QuickShareCache, 6 | } from "./AbstractCache"; 7 | 8 | export class FsCache extends AbstractCache { 9 | private _app: App; 10 | private _cache: CacheObject; 11 | 12 | constructor(app: App) { 13 | super(); 14 | this._app = app; 15 | } 16 | 17 | public async _init(): Promise { 18 | await this._fetchCache(); 19 | return this; 20 | } 21 | 22 | protected _getCache(): CacheObject { 23 | return this._cache ?? {}; 24 | } 25 | 26 | protected async _writeCache(object: CacheObject): Promise { 27 | await this._app.vault.adapter.write( 28 | this._cachePath, 29 | JSON.stringify(object, null, 2) 30 | ); 31 | this._cache = object; 32 | } 33 | 34 | private async _fetchCache(): Promise { 35 | try { 36 | const jsonString = await app.vault.adapter.read(this._cachePath); 37 | this._cache = JSON.parse(jsonString) as CacheObject; 38 | } catch (e) { 39 | this._cache = {}; 40 | } 41 | } 42 | 43 | public async $deleteAllData(): Promise { 44 | await this._app.vault.adapter.remove(this._cachePath); 45 | this._cache = {}; 46 | } 47 | 48 | private get _cachePath(): string { 49 | return `${this._app.vault.configDir}/quickshare.json`; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/lib/cache/LocalStorageCache.ts: -------------------------------------------------------------------------------- 1 | import type { App } from "obsidian"; 2 | import { 3 | AbstractCache, 4 | type CacheObject, 5 | type QuickShareCache, 6 | } from "./AbstractCache"; 7 | 8 | export class LocalStorageCache extends AbstractCache { 9 | private _app: App; 10 | private _cache: CacheObject; 11 | 12 | constructor(app: App) { 13 | super(); 14 | this._app = app; 15 | } 16 | 17 | public async _init(): Promise { 18 | await this._fetchCache(); 19 | return this; 20 | } 21 | 22 | protected _getCache(): CacheObject { 23 | return this._cache ?? {}; 24 | } 25 | 26 | protected async _writeCache(object: CacheObject): Promise { 27 | window.localStorage.setItem( 28 | this._cacheKey, 29 | JSON.stringify(object, null, 2) 30 | ); 31 | this._cache = object; 32 | } 33 | 34 | private async _fetchCache(): Promise { 35 | try { 36 | const jsonString = window.localStorage.getItem(this._cacheKey); 37 | if (jsonString) { 38 | this._cache = JSON.parse(jsonString) as CacheObject; 39 | } else { 40 | this._cache = {}; 41 | } 42 | } catch (e) { 43 | this._cache = {}; 44 | } 45 | } 46 | 47 | public async $deleteAllData(): Promise { 48 | window.localStorage.removeItem(this._cacheKey); 49 | this._cache = {}; 50 | } 51 | 52 | private get _cacheKey(): string { 53 | // @ts-ignore appId is an undocumented API property, see 54 | // https://discord.com/channels/686053708261228577/840286264964022302/1030208306242928664 55 | return `${this._app.appId}-quickshare`; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/lib/obsidian-svelte/Icon.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 | 35 | 36 | 67 | -------------------------------------------------------------------------------- /src/lib/obsidian-svelte/IconButton.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 |
47 | 48 | 69 | -------------------------------------------------------------------------------- /src/lib/obsidian-svelte/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Icon } from "./Icon.svelte"; 2 | export { default as IconButton } from "./IconButton.svelte"; 3 | export { useIcon } from "./useIcon"; 4 | -------------------------------------------------------------------------------- /src/lib/obsidian-svelte/useIcon.ts: -------------------------------------------------------------------------------- 1 | import { setIcon } from "obsidian"; 2 | 3 | /** 4 | * Mounts an Obsidian icon to an HTML element. 5 | */ 6 | export function useIcon(node: HTMLElement, name: string) { 7 | setIcon(node, name); 8 | 9 | return { 10 | update(name: string) { 11 | setIcon(node, name); 12 | }, 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/stores/ActiveCacheFile.ts: -------------------------------------------------------------------------------- 1 | import { derived } from "svelte/store"; 2 | import ActiveMdFile from "./ActiveMdFile"; 3 | import CacheStore from "./CacheStore"; 4 | 5 | const ActiveCacheFile = derived( 6 | [ActiveMdFile, CacheStore], 7 | ([$file, $cache]) => { 8 | if (!$file) return null; 9 | return { 10 | file: $file, 11 | cache: $cache.find((o) => o.fileId === $file.path), 12 | }; 13 | } 14 | ); 15 | 16 | export default ActiveCacheFile; 17 | -------------------------------------------------------------------------------- /src/lib/stores/ActiveMdFile.ts: -------------------------------------------------------------------------------- 1 | import type { TFile } from "obsidian"; 2 | import { writable } from "svelte/store"; 3 | 4 | const { subscribe, set } = writable(null); 5 | 6 | export function setActiveMdFile(file: TFile | null) { 7 | set(file); 8 | } 9 | 10 | export default { subscribe }; 11 | -------------------------------------------------------------------------------- /src/lib/stores/CacheStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | import type { QuickShareDataList } from "../cache/AbstractCache"; 3 | 4 | const { subscribe, set } = writable([]); 5 | 6 | export function updateReactiveCache(data: QuickShareDataList) { 7 | data.sort((a, b) => { 8 | const aDate = new Date(a.updated_datetime ?? a.shared_datetime); 9 | const bDate = new Date(b.updated_datetime ?? b.shared_datetime); 10 | return bDate.getTime() - aDate.getTime(); 11 | }); 12 | set(data); 13 | } 14 | 15 | export default { subscribe }; 16 | -------------------------------------------------------------------------------- /src/obsidian/Frontmatter.ts: -------------------------------------------------------------------------------- 1 | import type { App, TFile } from "obsidian"; 2 | 3 | type FrontmatterKey = "url" | "datetime"; 4 | 5 | const keyTypetoFrontmatterKey: Record = { 6 | url: "quickshare-url", 7 | datetime: "quickshare-date", 8 | }; 9 | 10 | function _getFrontmatterKey( 11 | file: TFile, 12 | key: FrontmatterKey, 13 | app: App 14 | ): string { 15 | const fmCache = app.metadataCache.getFileCache(file).frontmatter; 16 | return fmCache?.[keyTypetoFrontmatterKey[key]] || undefined; 17 | } 18 | 19 | function _setFrontmatterKey( 20 | file: TFile, 21 | key: FrontmatterKey, 22 | value: string, 23 | content: string 24 | ) { 25 | if (_getFrontmatterKey(file, key, app) === value) { 26 | console.log("returning"); 27 | return; 28 | } 29 | 30 | if (_getFrontmatterKey(file, key, app) !== undefined) { 31 | // replace the existing key. 32 | content = content.replace( 33 | new RegExp(`^(${keyTypetoFrontmatterKey[key]}):\\s*(.*)$`, "m"), 34 | `${keyTypetoFrontmatterKey[key]}: ${value}` 35 | ); 36 | } else { 37 | if (content.match(/^---/)) { 38 | // add the key to the existing block 39 | content = content.replace( 40 | /^---/, 41 | `---\n${keyTypetoFrontmatterKey[key]}: ${value}` 42 | ); 43 | } else { 44 | // create a new block 45 | content = `---\n${keyTypetoFrontmatterKey[key]}: ${value}\n---\n${content}`; 46 | } 47 | } 48 | 49 | return content; 50 | } 51 | 52 | async function _setFrontmatterKeys( 53 | file: TFile, 54 | records: Record, 55 | app: App 56 | ) { 57 | let content = await app.vault.read(file); 58 | for (const [key, value] of Object.entries(records)) { 59 | if (_getFrontmatterKey(file, key as FrontmatterKey, app) !== value) { 60 | content = _setFrontmatterKey( 61 | file, 62 | key as FrontmatterKey, 63 | value, 64 | content 65 | ); 66 | } 67 | } 68 | await app.vault.modify(file, content); 69 | } 70 | 71 | export function useFrontmatterHelper(app: App) { 72 | const getFrontmatterKey = (file: TFile, key: FrontmatterKey) => 73 | _getFrontmatterKey(file, key, app); 74 | 75 | const setFrontmatterKeys = ( 76 | file: TFile, 77 | records: Record 78 | ) => _setFrontmatterKeys(file, records, app); 79 | 80 | return { 81 | getFrontmatterKey, 82 | setFrontmatterKeys, 83 | }; 84 | } 85 | -------------------------------------------------------------------------------- /src/obsidian/PluginSettings.ts: -------------------------------------------------------------------------------- 1 | import { generateId } from "src/lib/anonUserId"; 2 | 3 | export interface PluginSettings { 4 | serverUrl: string; 5 | selfHosted: boolean; 6 | anonymousUserId: string; 7 | useFrontmatter: boolean; 8 | frontmatterDateFormat?: string; 9 | shareFilenameAsTitle: boolean; 10 | useFsCache: boolean; 11 | } 12 | 13 | export const DEFAULT_SETTINGS: PluginSettings = { 14 | serverUrl: "https://noteshare.space", 15 | selfHosted: false, 16 | anonymousUserId: generateId(), 17 | useFrontmatter: true, 18 | frontmatterDateFormat: "YYYY-MM-DD HH:mm:ss", 19 | shareFilenameAsTitle: true, 20 | useFsCache: true, 21 | }; 22 | -------------------------------------------------------------------------------- /src/obsidian/SettingsTab.ts: -------------------------------------------------------------------------------- 1 | import type NoteSharingPlugin from "main"; 2 | import { 3 | App, 4 | Notice, 5 | PluginSettingTab, 6 | Setting, 7 | TextComponent, 8 | } from "obsidian"; 9 | import { FsCache } from "src/lib/cache/FsCache"; 10 | import { LocalStorageCache } from "src/lib/cache/LocalStorageCache"; 11 | import { DEFAULT_SETTINGS } from "./PluginSettings"; 12 | 13 | export default class SettingsTab extends PluginSettingTab { 14 | plugin: NoteSharingPlugin; 15 | 16 | private selfHostSettings: HTMLElement; 17 | private frontmatterSettings: HTMLElement; 18 | private hideSelfHosted: boolean; 19 | private selfHostedUrl: TextComponent; 20 | 21 | constructor(app: App, plugin: NoteSharingPlugin) { 22 | super(app, plugin); 23 | this.plugin = plugin; 24 | this.hideSelfHosted = !plugin.settings.selfHosted; 25 | } 26 | 27 | display(): void { 28 | const { containerEl } = this; 29 | 30 | containerEl.empty(); 31 | 32 | // General settings 33 | containerEl.createEl("h2", { text: "QuickShare" }); 34 | 35 | new Setting(containerEl) 36 | .setName("Use noteshare.space") 37 | .setDesc( 38 | "Noteshare.space is the free and official service for sharing your notes with QuickShare. Uncheck if you want to self-host." 39 | ) 40 | .addToggle((text) => 41 | text 42 | .setValue(!this.plugin.settings.selfHosted) 43 | .onChange(async (value) => { 44 | this.plugin.settings.selfHosted = !value; 45 | this.showSelfhostedSettings( 46 | this.plugin.settings.selfHosted 47 | ); 48 | if (this.plugin.settings.selfHosted === false) { 49 | this.plugin.settings.serverUrl = 50 | DEFAULT_SETTINGS.serverUrl; 51 | this.selfHostedUrl.setValue( 52 | this.plugin.settings.serverUrl 53 | ); 54 | } 55 | await this.plugin.saveSettings(); 56 | }) 57 | ); 58 | 59 | new Setting(containerEl) 60 | .setName("Sync QuickShare data across devices") 61 | .setDesc( 62 | `By default, QuickShare keeps the access keys for your shared 63 | notes in a hidden file in your vault. This enables updating or deleting of QuickShare notes from multiple 64 | devices when using Obsidian Sync. If your vault folder is shared or public, it is recommended that you turn this setting off.` 65 | ) 66 | .addToggle((text) => 67 | text 68 | .setValue(this.plugin.settings.useFsCache) 69 | .onChange(async (value) => { 70 | try { 71 | const newCache = value 72 | ? await new FsCache(this.app).init() 73 | : await new LocalStorageCache(this.app).init(); 74 | await newCache.copy(this.plugin.$cache); 75 | await this.plugin.$cache.$deleteAllData(); 76 | this.plugin.$cache = newCache; 77 | } catch { 78 | new Notice( 79 | "Could not change cache type. Please report a bug." 80 | ); 81 | return; 82 | } 83 | this.plugin.settings.useFsCache = value; 84 | await this.plugin.saveSettings(); 85 | }) 86 | ); 87 | 88 | // Self-hosted settings 89 | this.selfHostSettings = containerEl.createDiv(); 90 | this.selfHostSettings.createEl("h2", { text: "Self-hosting options" }); 91 | new Setting(this.selfHostSettings) 92 | .setName("Server URL") 93 | .setDesc( 94 | "Server URL hosting the encrypted notes. For more information about self-hosting, see https://github.com/mcndt/noteshare.space#deployment" 95 | ) 96 | .addText((text) => { 97 | this.selfHostedUrl = text; 98 | text.setPlaceholder("enter URL") 99 | .setValue(this.plugin.settings.serverUrl) 100 | .onChange(async (value) => { 101 | this.plugin.settings.serverUrl = value; 102 | await this.plugin.saveSettings(); 103 | }); 104 | }); 105 | 106 | this.showSelfhostedSettings(this.plugin.settings.selfHosted); 107 | 108 | // Sharing settings 109 | containerEl.createEl("h2", { text: "Sharing options" }); 110 | new Setting(containerEl) 111 | .setName("Share filename as note title") 112 | .setDesc( 113 | 'Use the filename as the title of the note (like "Show inline title" in Obsidian\'s appearance settings). If unchecked, the title will be the first heading in the note.' 114 | ) 115 | .addToggle((text) => 116 | text 117 | .setValue(this.plugin.settings.shareFilenameAsTitle) 118 | .onChange(async (value) => { 119 | this.plugin.settings.shareFilenameAsTitle = value; 120 | await this.plugin.saveSettings(); 121 | }) 122 | ); 123 | 124 | // Frontmatter settings 125 | containerEl.createEl("h2", { text: "Frontmatter options" }); 126 | 127 | new Setting(containerEl) 128 | .setName("Use frontmatter") 129 | .setDesc( 130 | "Use frontmatter to store the QuickShare URL and share date after sharing." 131 | ) 132 | .addToggle((text) => 133 | text 134 | .setValue(this.plugin.settings.useFrontmatter) 135 | .onChange(async (value) => { 136 | this.plugin.settings.useFrontmatter = value; 137 | await this.plugin.saveSettings(); 138 | this.showFrontmatterSettings( 139 | this.plugin.settings.useFrontmatter 140 | ); 141 | }) 142 | ); 143 | 144 | // Frontmatter date format 145 | this.frontmatterSettings = containerEl.createDiv(); 146 | 147 | new Setting(this.frontmatterSettings) 148 | .setName("Frontmatter date format") 149 | .setDesc( 150 | "See https://momentjs.com/docs/#/displaying/format/ for formatting options." 151 | ) 152 | .addMomentFormat((text) => 153 | text 154 | .setDefaultFormat(DEFAULT_SETTINGS.frontmatterDateFormat) 155 | .setValue(this.plugin.settings.frontmatterDateFormat) 156 | .onChange(async (value) => { 157 | this.plugin.settings.frontmatterDateFormat = value; 158 | await this.plugin.saveSettings(); 159 | }) 160 | ); 161 | 162 | this.showFrontmatterSettings(this.plugin.settings.useFrontmatter); 163 | } 164 | 165 | private showSelfhostedSettings(show: boolean) { 166 | this.selfHostSettings.hidden = !show; 167 | } 168 | 169 | private showFrontmatterSettings(show: boolean) { 170 | this.frontmatterSettings.hidden = !show; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | // Solution for adding crypto definitions: https://stackoverflow.com/questions/71525466/property-subtle-does-not-exist-on-type-typeof-webcrypto 2 | 3 | declare module "crypto" { 4 | namespace webcrypto { 5 | const subtle: SubtleCrypto; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/QuickShareSideView.svelte: -------------------------------------------------------------------------------- 1 | 105 | 106 |
107 | {#if $ActiveCacheFile?.file} 108 |
109 |
110 | 129 |
130 |
131 | {#if !$ActiveCacheFile?.cache || !isShared($ActiveCacheFile?.cache)} 132 | 135 | {:else} 136 |
137 | onShare($ActiveCacheFile.file)} 141 | tooltip="Share again" 142 | /> 143 | 147 | onUnshare($ActiveCacheFile?.cache.fileId)} 148 | tooltip="Remove access" 149 | /> 150 |
151 | {/if} 152 |
153 |
154 | 155 |
156 | {/if} 157 | 158 |
159 |
Recently shared
160 |
161 | {#each filteredData as item} 162 | 163 |
173 |
174 |
177 | !deletedFromVault(item) && 178 | onOpenNote(item.fileId)} 179 | > 180 |
181 | {item.basename} 182 | {#if deletedFromVault(item)} 183 | 184 | (Deleted from vault) 185 | 186 | {/if} 187 |
188 |
189 | {getSubText(item)} 190 |
191 |
192 | 193 | {#if !hasExpired(item) && !deletedFromServer(item)} 194 |
195 | onOpen(item.view_url)} 199 | tooltip="Open in browser" 200 | /> 201 | onUnshare(item.fileId)} 205 | tooltip="Remove access" 206 | /> 207 |
208 | {/if} 209 |
210 |
211 | {/each} 212 |
213 |
214 |
215 | 216 | 333 | -------------------------------------------------------------------------------- /src/ui/QuickShareSideView.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf } from "obsidian"; 2 | import QuickShareSideViewComponent from "./QuickShareSideView.svelte"; 3 | export class QuickShareSideView extends ItemView { 4 | static viewType = "QUICKSHARE_SIDE_VIEW"; 5 | 6 | public navigation = false; 7 | 8 | private component: QuickShareSideViewComponent; 9 | 10 | constructor(leaf: WorkspaceLeaf) { 11 | super(leaf); 12 | } 13 | 14 | /* Obsidian event lifecycle */ 15 | 16 | async onOpen(): Promise { 17 | this.component = new QuickShareSideViewComponent({ 18 | target: this.contentEl, 19 | }); 20 | } 21 | 22 | async onClose() { 23 | this.component.$destroy(); 24 | } 25 | 26 | /* View abstract method implementations */ 27 | 28 | getViewType(): string { 29 | return QuickShareSideView.viewType; 30 | } 31 | 32 | getDisplayText(): string { 33 | return "QuickShare"; 34 | } 35 | 36 | getIcon(): string { 37 | return "cloud"; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/ui/SharedNoteSuccessComponent.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | 51 | 52 | 86 | -------------------------------------------------------------------------------- /src/ui/SharedNoteSuccessModal.ts: -------------------------------------------------------------------------------- 1 | import type NoteSharingPlugin from "main"; 2 | import { Modal } from "obsidian"; 3 | import type { Moment } from "moment"; 4 | import Component from "./SharedNoteSuccessComponent.svelte"; 5 | 6 | export class SharedNoteSuccessModal extends Modal { 7 | private url: string; 8 | private component: Component; 9 | private expire_time: Moment; 10 | 11 | constructor(plugin: NoteSharingPlugin, url: string, expire_time: Moment) { 12 | super(plugin.app); 13 | this.url = url; 14 | this.expire_time = expire_time; 15 | this.render(); 16 | } 17 | 18 | render() { 19 | this.titleEl.innerText = "Shared note"; 20 | } 21 | 22 | async onOpen() { 23 | this.component = new Component({ 24 | target: this.contentEl, 25 | props: { 26 | url: this.url, 27 | expireTime: this.expire_time, 28 | }, 29 | }); 30 | } 31 | 32 | async onClose() { 33 | this.component.$destroy(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* Dummy class to fool the build system into not producing an error */ 2 | .dummy { 3 | background: #fff; 4 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["svelte", "node"], 5 | "baseUrl": ".", 6 | "inlineSources": true, 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "outDir": "./build" /* Specify an output folder for all emitted files. */, 15 | "lib": ["dom", "ES5", "ES6", "ES7"], 16 | "typeRoots": ["./node_modules/@types", "./src/types"] 17 | }, 18 | "include": ["**/*.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.0": "0.13.25", 3 | "0.1.1": "0.13.25", 4 | "0.2.0": "0.13.25", 5 | "0.2.1": "0.13.25", 6 | "0.3.0": "0.13.25", 7 | "0.3.1": "0.13.25", 8 | "0.4.0": "0.13.25", 9 | "0.4.1": "0.13.25", 10 | "1.0.0": "0.13.25", 11 | "1.0.1": "0.13.25", 12 | "1.0.2": "0.13.25", 13 | "1.1.0": "0.13.25", 14 | "1.1.1": "0.13.25", 15 | "1.2.0": "0.13.25", 16 | "1.3.0": "0.13.25", 17 | "1.3.1": "0.13.25" 18 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "happy-dom", 6 | }, 7 | }); 8 | --------------------------------------------------------------------------------