Rebuilt the codebase as a plugin

This commit is contained in:
Alan Grainger 2023-09-07 12:07:54 +02:00
parent 80be4c652a
commit 7e2fd3a3a3
29 changed files with 4386 additions and 453 deletions

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
main.js

25
.eslintrc Normal file
View file

@ -0,0 +1,25 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": [
"@typescript-eslint"
],
"extends": [
"standard",
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-new": 0,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
}
}

23
.gitignore vendored
View file

@ -1 +1,22 @@
.idea/
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store

0
.hotreload Normal file
View file

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

View file

@ -1,37 +1,12 @@
# Obsidian Share
There are times I want to create a public link to one of my notes, without having an entire "digital garden" through Obsidian Publish.
Enter **Obsidian Share**. To share a page, just run a single Templater script. Templater is also optional - it's plain Javascript so you can launch it from anywhere.
[See it live in action here](https://file.obsidianshare.com/572e1ae4a0aeadf5943862d1deaf8fe6.html), along with installation instructions and usage.
For any feedback or issues [post those in the forum thread](https://forum.obsidian.md/t/42788/).
You can also host your own private version, [see details here](https://file.obsidianshare.com/5d9dadb08ee4ec00323c694930722702.html).
Free, encrypted public note sharing for Obsidian.
## Features
🔹 Uploads using your current theme.
🔹 Local and remote image support.
🔹 Supports anything that Obsidian Preview mode does, like rendered Dataview queries and any custom CSS you might have enabled.
🔹 Supports callouts with full styling:
🔹 Filenames are anonymised through hashing so people can't discover your other shared notes.
🔹 If your shared note links to another note which is also shared, that link will also function on the shared webpage.
🔹 Frontmatter is stripped on upload to avoid leaking unwanted data.
## Installation
There are two options to running Obsidian Share:
1. No setup required, just use my public share server. [See the instructions here](https://file.obsidianshare.com/572e1ae4a0aeadf5943862d1deaf8fe6.html).
or
2. Set up your own private server. [See the instructions here](https://file.obsidianshare.com/5d9dadb08ee4ec00323c694930722702.html).
- Uploads using your current theme.
- Local and remote image support.
- Supports anything that Obsidian Preview mode does, like rendered Dataview queries and any custom CSS you might have enabled.
- Supports callouts with full styling.
- If your shared note links to another note which is also shared, that link will also function on the public page.
- Frontmatter is stripped on upload by default to avoid leaking unwanted data.

48
esbuild.config.mjs Normal file
View file

@ -0,0 +1,48 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View file

@ -1 +0,0 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

11
manifest.json Normal file
View file

@ -0,0 +1,11 @@
{
"id": "share-note",
"name": "Share Note",
"version": "0.1.0",
"minAppVersion": "0.15.0",
"description": "Share a note publicly with full styling. Data is stored encrypted on the server and only you have the key.",
"author": "Alan Grainger",
"authorUrl": "https://github.com/alangrainger",
"fundingUrl": "https://ko-fi.com/alan_",
"isDesktopOnly": false
}

View file

@ -1,193 +0,0 @@
// Add a unique identifier for yourself. At some point in the future I might add user accounts, so it would be a good
// idea to use your email address. **This will not be sent to the server!** Privacy first.
const ACCOUNT_ID = 'A unique ID'
const YAML_FIELD = 'share'
const WIDTH = 700
const SHOW_FOOTER = true
/*
* Obsidian Share Public
*
* Created by Alan Grainger
* https://github.com/alangrainger/obsidian-share/
*
* v1.2.0
*/
const fs = require('fs')
const leaf = app.workspace.activeLeaf
const startMode = leaf.getViewState()
const previewMode = leaf.getViewState()
previewMode.state.mode = 'preview'
leaf.setViewState(previewMode)
await new Promise(resolve => { setTimeout(() => { resolve() }, 200) })
// Parse the current document
let content, body, previewView, css
try {
content = leaf.view.modes.preview.renderer.sections.reduce((p, c) => p + c.el.innerHTML, '')
body = document.getElementsByTagName('body')[0]
previewView = document.getElementsByClassName('markdown-preview-view markdown-rendered')[0]
css = [...document.styleSheets].map(x => {
try { return [...x.cssRules].map(x => x.cssText).join('') }
catch (e) { }
}).filter(Boolean).join('').replace(/\n/g, '')
} catch (e) {
console.log(e)
new Notice('Failed to parse current note, check console for details', 5000)
}
// Revert to the original view mode
setTimeout(() => { leaf.setViewState(startMode) }, 200)
if (!previewView) return // Failed to parse current note
const status = new Notice('Sharing note...', 60000)
async function sha256(text) {
const encoder = new TextEncoder();
const data = encoder.encode(text)
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('')
}
const getHash = async (path) => { return (await sha256(path)).slice(0, 32) }
const id = await getHash(ACCOUNT_ID)
function updateFrontmatter(contents, field, value) {
const f = contents.match(/^---\r?\n(.*?)\n---\r?\n(.*)$/s),
v = `${field}: ${value}`,
x = new RegExp(`^${field}:.*$`, 'm'),
[s, e] = f ? [`${f[1]}\n`, f[2]] : ['', contents]
return f && f[1].match(x) ? contents.replace(x, v) : `---\n${s}${v}\n---\n${e}`
}
async function upload(data) {
data.id = id
status.setMessage(`Uploading ${data.filename}...`)
return requestUrl({
url: 'https://file.obsidianshare.com/sharefile.php',
method: 'POST',
timeout: 1000,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
}
function extension(mimeType) {
const mimes = {
ttf: ['font/ttf', 'application/x-font-ttf', 'application/x-font-truetype', 'font/truetype'],
otf: ['font/otf', 'application/x-font-opentype'],
woff: ['font/woff', 'application/font-woff', 'application/x-font-woff'],
woff2: ['font/woff2', 'application/font-woff2', 'application/x-font-woff2'],
}
return Object.keys(mimes).find(x => mimes[x].includes((mimeType || '').toLowerCase()))
}
const file = app.workspace.getActiveFile()
const footer = '<div class="status-bar"><div class="status-bar-item"><span class="status-bar-item-segment">Published with <a href="https://obsidianshare.com/" target="_blank">Obsidian Share</a></span></div></div>'
let html = `
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${file.basename}</title>
<meta property="og:title" content="${file.basename}" />
<meta id="head-description" name="description" content="">
<meta id="head-og-description" property="og:description" content="">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="${id}.css">
<style>
html, body {
overflow: visible !important;
}
.view-content {
height: 100% !important;
}
</style>
</head>
<body class="${body.className}" style="${body.style.cssText.replace(/"/g, `'`)}">
<div class="app-container">
<div class="horizontal-main-container">
<div class="workspace">
<div class="workspace-split mod-vertical mod-root">
<div class="workspace-leaf mod-active">
<div class="workspace-leaf-content">
<div class="view-content">
<div class="markdown-reading-view" style="max-width:${WIDTH}px;margin: 0 auto;">
<div class="${previewView.className}">
<div class="markdown-preview-sizer markdown-preview-section">
${content}
</div></div></div></div></div></div></div></div></div>${SHOW_FOOTER ? footer : ''}</div></body></html>`
try {
// Generate the HTML file for uploading
const dom = new DOMParser().parseFromString(html, 'text/html')
// Remove frontmatter to avoid sharing unwanted data
dom.querySelector('pre.frontmatter')?.remove()
dom.querySelector('div.frontmatter-container')?.remove()
// Set the meta description and OG description
const meta = app.metadataCache.getFileCache(file)
try {
const desc = Array.from(dom.querySelectorAll("p")).map(x => x.innerText).filter(x => !!x).join(' ').slice(0, 200) + '...'
dom.querySelector('#head-description').content = desc
dom.querySelector('#head-og-description').content = desc
} catch (e) { }
// Replace links
for (const el of dom.querySelectorAll("a.internal-link")) {
if (href = el.getAttribute('href').match(/^([^#]+)/)) {
const file = app.metadataCache.getFirstLinkpathDest(href[1], '')
if (meta?.frontmatter?.[YAML_FIELD + '_link']) {
// This file is shared, so update the link with the share URL
el.setAttribute('href', meta.frontmatter[YAML_FIELD + '_link'])
el.removeAttribute('target')
continue
}
}
// This file is not shared, so remove the link and replace with plain-text
el.replaceWith(el.innerText)
}
// Upload local images
for (const el of dom.querySelectorAll('img')) {
const src = el.getAttribute('src')
if (!src.startsWith('app://')) continue
try {
const localFile = window.decodeURIComponent(src.match(/app:\/\/\w+\/([^?#]+)/)[1])
const url = (await getHash(id + localFile)) + '.' + localFile.split('.').pop()
el.setAttribute('src', url)
el.removeAttribute('alt')
await upload({ filename: url, content: fs.readFileSync(localFile, { encoding: 'base64' }), encoding: 'base64' })
} catch (e) {
console.log(e)
}
}
// Share the file
const shareName = meta?.frontmatter?.[YAML_FIELD + '_hash'] || await getHash(id + file.path)
const shareFile = shareName + '.html'
await upload({ filename: shareFile, content: dom.documentElement.innerHTML })
// Upload theme CSS, unless this file has previously been shared
// To force a CSS re-upload, just remove the `share_link` frontmatter field
if (!meta?.frontmatter?.[YAML_FIELD + '_link']) {
await upload({ filename: id + '.css', content: css })
// Extract any base64 encoded attachments from the CSS.
// Will use the mime-type list above to determine which attachments to extract.
const regex = /url\s*\(\W*data:([^;,]+)[^)]*?base64\s*,\s*([A-Za-z0-9/=+]+).?\)/
for (const attachment of css.match(new RegExp(regex, 'g')) || []) {
if (match = attachment.match(new RegExp(regex))) {
if (extension(match[1])) {
const filename = (await getHash(match[2])) + `.${extension(match[1])}`
css = css.replace(match[0], `url("${filename}")`)
await upload({ filename: filename, content: match[2], encoding: 'base64' })
}
}
}
}
// Update the frontmatter in the current note
let contents = await app.vault.read(file)
contents = updateFrontmatter(contents, YAML_FIELD + '_updated', moment().format())
contents = updateFrontmatter(contents, YAML_FIELD + '_link', 'https://file.obsidianshare.com/' + shareFile)
app.vault.modify(file, contents)
status.hide()
new Notice('File has been shared', 4000)
} catch (e) {
console.log(e)
status.hide()
new Notice('Failed to share file', 4000)
}

View file

@ -1,205 +0,0 @@
const UPLOAD_LOCATION = 'https://example.com/somepath/' // the web root where the files will be uploaded. End in a trailing slash.
const UPLOAD_ENDPOINT = 'upload.php' // path to the upload endpoint, relative to UPLOAD_LOCATION
const YAML_FIELD = 'share'
const SECRET = 'some_fancy_secret'
const WIDTH = 700
const SHOW_FOOTER = true
/*
* Obsidian Share
*
* Created by Alan Grainger
* https://github.com/alangrainger/obsidian-share/
*
* v1.2.0
*/
const fs = require('fs')
const leaf = app.workspace.activeLeaf
const startMode = leaf.getViewState()
// Switch to Preview mode
const previewMode = leaf.getViewState()
previewMode.state.mode = 'preview'
leaf.setViewState(previewMode)
await new Promise(resolve => { setTimeout(() => { resolve() }, 200) })
// Parse the current document
let content, body, previewView, css
try {
content = leaf.view.modes.preview.renderer.sections.reduce((p, c) => p + c.el.innerHTML, '')
body = document.getElementsByTagName('body')[0]
previewView = document.getElementsByClassName('markdown-preview-view markdown-rendered')[0]
css = [...document.styleSheets].map(x => {
try { return [...x.cssRules].map(x => x.cssText).join('') }
catch (e) { }
}).filter(Boolean).join('').replace(/\n/g, '')
} catch (e) {
console.log(e)
new Notice('Failed to parse current note, check console for details', 5000)
}
// Revert to the original view mode
setTimeout(() => { leaf.setViewState(startMode) }, 200)
if (!previewView) return // Failed to parse current note
const status = new Notice('Sharing note...', 60000)
async function sha256(text) {
const encoder = new TextEncoder();
const data = encoder.encode(text)
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('')
}
const getHash = async (path) => { return (await sha256(path)).slice(0, 32) }
function updateFrontmatter(contents, field, value) {
const f = contents.match(/^---\r?\n(.*?)\n---\r?\n(.*)$/s),
v = `${field}: ${value}`,
x = new RegExp(`^${field}:.*$`, 'm'),
[s, e] = f ? [`${f[1]}\n`, f[2]] : ['', contents]
return f && f[1].match(x) ? contents.replace(x, v) : `---\n${s}${v}\n---\n${e}`
}
/**
* Upload to web server
* Will add two additional properties to the POST data:
* 'nonce' - here using millisecond timestamp
* 'auth' - SHA256 of nonce + SECRET
* @param {Object} data - An object with the following properties:
* @param {string} data.filename - Filename for the destination file
* @param {string} data.content - File content
* @param {string} [data.encoding] - Optional encoding type, accepts only 'base64'
*/
async function upload(data) {
data.nonce = Date.now().toString()
data.auth = await sha256(data.nonce + SECRET)
status.setMessage(`Uploading ${data.filename}...`)
return requestUrl({ url: UPLOAD_LOCATION + UPLOAD_ENDPOINT, method: 'POST', body: JSON.stringify(data) })
}
/**
* Convert mime-type to file extension
* If you want any additional base64 encoded files to be extracted from your CSS,
* add the extension and mime-type(s) here.
* @param {string} mimeType
* @returns {string} File extension
*/
function extension(mimeType) {
const mimes = {
ttf: ['font/ttf', 'application/x-font-ttf', 'application/x-font-truetype', 'font/truetype'],
otf: ['font/otf', 'application/x-font-opentype'],
woff: ['font/woff', 'application/font-woff', 'application/x-font-woff'],
woff2: ['font/woff2', 'application/font-woff2', 'application/x-font-woff2'],
}
return Object.keys(mimes).find(x => mimes[x].includes((mimeType || '').toLowerCase()))
}
const file = app.workspace.getActiveFile()
const footer = '<div class="status-bar"><div class="status-bar-item"><span class="status-bar-item-segment">Published with <a href="https://obsidianshare.com/" target="_blank">Obsidian Share</a></span></div></div>'
let html = `
<!DOCTYPE HTML>
<html>
<head>
<title>${file.basename}</title>
<meta property="og:title" content="${file.basename}" />
<meta id="head-description" name="description" content="">
<meta id="head-og-description" property="og:description" content="">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css">
<style>
html, body {
overflow: visible !important;
}
.view-content {
height: 100% !important;
}
</style>
</head>
<body class="${body.className}" style="${body.style.cssText.replace(/"/g, `'`)}">
<div class="app-container">
<div class="horizontal-main-container">
<div class="workspace">
<div class="workspace-split mod-vertical mod-root">
<div class="workspace-leaf mod-active">
<div class="workspace-leaf-content">
<div class="view-content">
<div class="markdown-reading-view" style="max-width:${WIDTH}px;margin: 0 auto;">
<div class="${previewView.className}">
<div class="markdown-preview-sizer markdown-preview-section">
${content}
</div></div></div></div></div></div></div></div></div>${SHOW_FOOTER ? footer : ''}</div></body></html>`
try {
// Generate the HTML file for uploading
const dom = new DOMParser().parseFromString(html, 'text/html')
// Remove frontmatter to avoid sharing unwanted data
dom.querySelector('pre.frontmatter')?.remove()
dom.querySelector('div.frontmatter-container')?.remove()
// Set the meta description and OG description
const meta = app.metadataCache.getFileCache(file)
try {
const desc = Array.from(dom.querySelectorAll("p")).map(x => x.innerText).filter(x => !!x).join(' ').slice(0, 200) + '...'
dom.querySelector('#head-description').content = desc
dom.querySelector('#head-og-description').content = desc
} catch (e) { }
// Replace links
for (const el of dom.querySelectorAll("a.internal-link")) {
if (href = el.getAttribute('href').match(/^([^#]+)/)) {
const file = app.metadataCache.getFirstLinkpathDest(href[1], '')
if (meta?.frontmatter?.[YAML_FIELD + '_link']) {
// This file is shared, so update the link with the share URL
el.setAttribute('href', meta.frontmatter[YAML_FIELD + '_link'])
el.removeAttribute('target')
continue
}
}
// This file is not shared, so remove the link and replace with plain-text
el.replaceWith(el.innerText)
}
// Upload local images
for (const el of dom.querySelectorAll('img')) {
const src = el.getAttribute('src')
if (!src.startsWith('app://')) continue
try {
const localFile = window.decodeURIComponent(src.match(/app:\/\/\w+\/([^?#]+)/)[1])
const url = (await getHash(localFile)) + '.' + localFile.split('.').pop()
el.setAttribute('src', url)
el.removeAttribute('alt')
await upload({ filename: url, content: fs.readFileSync(localFile, { encoding: 'base64' }), encoding: 'base64' })
} catch (e) {
console.log(e)
}
}
// Share the file
const shareName = meta?.frontmatter?.[YAML_FIELD + '_hash'] || await getHash(file.path)
const shareFile = shareName + '.html'
await upload({ filename: shareFile, content: dom.documentElement.innerHTML })
// Upload theme CSS, unless this file has previously been shared
// To force a CSS re-upload, just remove the `share_link` frontmatter field
if (!meta?.frontmatter?.[YAML_FIELD + '_link']) {
await upload({ filename: 'style.css', content: css })
// Extract any base64 encoded attachments from the CSS.
// Will use the mime-type list above to determine which attachments to extract.
const regex = /url\s*\(\W*data:([^;,]+)[^)]*?base64\s*,\s*([A-Za-z0-9/=+]+).?\)/
for (const attachment of css.match(new RegExp(regex, 'g')) || []) {
if (match = attachment.match(new RegExp(regex))) {
if (extension(match[1])) {
const filename = (await getHash(match[2])) + `.${extension(match[1])}`
css = css.replace(match[0], `url("${filename}")`)
await upload({ filename: filename, content: match[2], encoding: 'base64' })
}
}
}
}
// Update the frontmatter in the current note
let contents = await app.vault.read(file)
contents = updateFrontmatter(contents, YAML_FIELD + '_updated', moment().format())
contents = updateFrontmatter(contents, YAML_FIELD + '_link', `${UPLOAD_LOCATION}${shareFile}`)
app.vault.modify(file, contents)
status.hide()
new Notice('File has been shared', 4000)
} catch (e) {
console.log(e)
status.hide()
new Notice('Failed to share file', 4000)
}

3520
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "share-note",
"version": "0.1.0",
"description": "Share a note publicly with full styling. Data is stored encrypted on the server and only you have the key.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "Alan Grainger",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"eslint-config-standard": "^17.1.0",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
}
}

42
src/api.ts Normal file
View file

@ -0,0 +1,42 @@
import { requestUrl } from 'obsidian'
import SharePlugin from './main'
const BASEURL = 'https://api.obsidianshare.com'
export interface UploadData {
filename: string;
content: string;
encoding?: string;
}
export default class API {
plugin: SharePlugin
constructor (plugin: SharePlugin) {
this.plugin = plugin
}
async post (endpoint: string, data: object) {
Object.assign(data, {
id: this.plugin.settings.uid,
key: this.plugin.settings.apiKey
})
return requestUrl({
url: BASEURL + endpoint,
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
}
async upload (data: UploadData) {
const res = await this.post('/upload', data)
if (res.status === 200) {
return 'https://file.obsidianshare.com/' + data.filename
} else {
return ''
}
}
}

152
src/crypto.ts Normal file
View file

@ -0,0 +1,152 @@
// Copied with thanks from https://github.com/mcndt/obsidian-quickshare
interface EncryptedData {
ciphertext: string;
iv: string;
}
export interface EncryptedString {
ciphertext: string;
key: string;
iv: string;
}
/**
* Generates a 256-bit key from a seed.
*
* @param seed passphrase-like data to generate the key from.
*/
export async function generateKey (seed: string): Promise<ArrayBuffer> {
const _seed = new TextEncoder().encode(seed)
return _generateKey(_seed)
}
/**
* Generates a random 256-bit key using crypto.getRandomValues.
*/
export async function generateRandomKey (): Promise<ArrayBuffer> {
const seed = window.crypto.getRandomValues(new Uint8Array(64))
return _generateKey(seed)
}
async function _generateKey (seed: ArrayBuffer) {
const keyMaterial = await window.crypto.subtle.importKey(
'raw',
seed,
{ name: 'PBKDF2' },
false,
['deriveBits']
)
const masterKey = await window.crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: new Uint8Array(16),
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
256
)
return new Uint8Array(masterKey)
}
export function masterKeyToString (masterKey: ArrayBuffer): string {
return arrayBufferToBase64(masterKey)
}
export async function _encryptString (
md: string,
secret: ArrayBuffer
): Promise<EncryptedData> {
const plaintext = new TextEncoder().encode(md)
const iv = window.crypto.getRandomValues(new Uint8Array(16))
const bufCiphertext: ArrayBuffer = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
await _getAesGcmKey(secret),
plaintext
)
const ciphertext = arrayBufferToBase64(bufCiphertext)
return { ciphertext, iv: arrayBufferToBase64(iv) }
}
export async function decryptString (
{ ciphertext, iv }: EncryptedData,
secret: string
): Promise<string> {
const ciphertextBuf = base64ToArrayBuffer(ciphertext)
const ivBuf = base64ToArrayBuffer(iv)
const plaintext = await window.crypto.subtle
.decrypt(
{ name: 'AES-GCM', iv: ivBuf },
await _getAesGcmKey(base64ToArrayBuffer(secret)),
ciphertextBuf
)
.catch(() => {
throw new Error('Cannot decrypt ciphertext with this key.')
})
return new TextDecoder().decode(plaintext)
}
export function arrayBufferToBase64 (buffer: ArrayBuffer): string {
return window.btoa(String.fromCharCode(...new Uint8Array(buffer)))
}
export function base64ToArrayBuffer (base64: string): ArrayBuffer {
return Uint8Array.from(window.atob(base64), (c) => c.charCodeAt(0))
}
function _getAesGcmKey (secret: ArrayBuffer): Promise<CryptoKey> {
return window.crypto.subtle.importKey(
'raw',
secret,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
}
/**
* Encrypt a plaintext string with AES 256
*
* @param {string} plaintext
* @param {string} [existingKey] - Optional
* @return {EncryptedString}
*/
export async function encryptString (plaintext: string, existingKey?: string): Promise<EncryptedString> {
let key
if (existingKey) {
key = base64ToArrayBuffer(existingKey)
} else {
key = await _generateKey(window.crypto.getRandomValues(new Uint8Array(64)))
}
const encodedText = new TextEncoder().encode(plaintext)
const iv = window.crypto.getRandomValues(new Uint8Array(16))
const bufCiphertext: ArrayBuffer = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
await _getAesGcmKey(key),
encodedText
)
const ciphertext = arrayBufferToBase64(bufCiphertext)
return {
ciphertext,
iv: arrayBufferToBase64(iv),
key: masterKeyToString(key).slice(0, 43)
}
}
async function sha256 (text: string) {
const encoder = new TextEncoder()
const data = encoder.encode(text)
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('')
}
export async function hash (path: string) {
return (await sha256(path)).slice(0, 32)
}

38
src/main.ts Normal file
View file

@ -0,0 +1,38 @@
import { Plugin } from 'obsidian'
import { ShareSettings, ShareSettingsTab, DEFAULT_SETTINGS } from './settings'
import Note from './note'
import API from './api'
export default class SharePlugin extends Plugin {
settings: ShareSettings
api: API
async onload () {
await this.loadSettings()
this.api = new API(this)
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'share',
name: 'Share current note',
callback: () => {
const note = new Note(this)
note.parse()
}
})
this.addSettingTab(new ShareSettingsTab(this.app, this))
}
onunload () {
}
async loadSettings () {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData())
}
async saveSettings () {
await this.saveData(this.settings)
}
}

220
src/note.ts Normal file
View file

@ -0,0 +1,220 @@
import { CachedMetadata, moment, Notice, TFile, WorkspaceLeaf } from 'obsidian'
import Template, { Placeholder, defaultFooter } from './template'
import { encryptString, hash } from './crypto'
import SharePlugin from './main'
import { UploadData } from './api'
import * as fs from 'fs'
export default class Note {
plugin: SharePlugin
leaf: WorkspaceLeaf
status: Notice
content: string
previewViewEl: Element
css: string
dom: Document
meta: CachedMetadata | null
constructor (plugin: SharePlugin) {
this.plugin = plugin
this.leaf = this.plugin.app.workspace.getLeaf()
}
async parse () {
const startMode = this.leaf.getViewState()
const previewMode = this.leaf.getViewState()
previewMode.state.mode = 'preview'
await this.leaf.setViewState(previewMode)
// Even though we 'await', sometimes the view isn't ready. This helps reduce no-content errors
await new Promise(resolve => setTimeout(resolve, 200))
try {
// @ts-ignore // 'view.modes'
this.content = this.leaf.view.modes.preview.renderer.sections.reduce((p, c) => p + c.el.innerHTML, '')
// Fetch the preview view classes
this.previewViewEl = document.getElementsByClassName('markdown-preview-view markdown-rendered')[0]
this.css = [...Array.from(document.styleSheets)].map(x => {
try {
return [...Array.from(x.cssRules)].map(x => x.cssText).join('')
} catch (e) {
return ''
}
}).filter(Boolean).join('').replace(/\n/g, '')
} catch (e) {
console.log(e)
new Notice('Failed to parse current note, check console for details', 5000)
return
}
if (!this.content) {
new Notice('Failed to read current note, please try again.', 5000)
return
}
// Reset the view to the original mode
// The timeout is required, even though we 'await' the preview mode setting earlier
setTimeout(() => { this.leaf.setViewState(startMode) }, 200)
this.status = new Notice('Sharing note...', 60000)
const file = this.plugin.app.workspace.getActiveFile()
if (!(file instanceof TFile)) {
// No active file
return
}
this.meta = this.plugin.app.metadataCache.getFileCache(file)
const outputFile = new Template()
// Make template value replacements
outputFile.set(Placeholder.css, `${this.plugin.settings.uid}.css`)
outputFile.set(Placeholder.noteWidth, this.plugin.settings.noteWidth)
outputFile.set(Placeholder.previewViewClass, this.previewViewEl.className || '')
outputFile.set(Placeholder.bodyClass, document.body.className)
outputFile.set(Placeholder.bodyStyle, document.body.style.cssText.replace(/"/g, '\''))
outputFile.set(Placeholder.footer, this.plugin.settings.showFooter ? defaultFooter : '')
// Generate the HTML file for uploading
this.dom = new DOMParser().parseFromString(this.content, 'text/html')
if (this.plugin.settings.removeYaml) {
// Remove frontmatter to avoid sharing unwanted data
this.dom.querySelector('div.metadata-container')?.remove()
this.dom.querySelector('pre.frontmatter')?.remove()
this.dom.querySelector('div.frontmatter-container')?.remove()
}
// Replace links
for (const el of this.dom.querySelectorAll('a.internal-link')) {
const hrefEl = el.getAttribute('href')
const href = hrefEl ? hrefEl.match(/^([^#]+)/) : null
if (href) {
const linkedFile = this.plugin.app.metadataCache.getFirstLinkpathDest(href[1], '')
if (linkedFile instanceof TFile) {
const linkedMeta = this.plugin.app.metadataCache.getFileCache(linkedFile)
if (linkedMeta?.frontmatter?.[this.plugin.settings.yamlField + '_link']) {
// This file is shared, so update the link with the share URL
el.setAttribute('href', linkedMeta.frontmatter[this.plugin.settings.yamlField + '_link'])
el.removeAttribute('target')
continue
}
}
}
// This file is not shared, so remove the link and replace with the non-link content
el.replaceWith(el.innerHTML)
}
// Upload local images
await this.processImages()
// Encrypt the note content
// Use previous key if it exists, so that links will stay consistent across updates
let existingKey
if (this.meta?.frontmatter?.[this.plugin.settings.yamlField + '_link']) {
const key = this.meta.frontmatter[this.plugin.settings.yamlField + '_link'].match(/#(.+)$/)
if (key) {
existingKey = key[1]
}
}
const plaintext = JSON.stringify({
content: this.dom.body.innerHTML,
basename: file.basename
})
const encryptedData = await encryptString(plaintext, existingKey)
outputFile.set(Placeholder.payload, JSON.stringify({
ciphertext: encryptedData.ciphertext,
iv: encryptedData.iv
}))
// Share the file
const shareName = this.meta?.frontmatter?.[this.plugin.settings.yamlField + '_hash'] || await hash(this.plugin.settings.uid + file.path)
const shareFile = shareName + '.html'
const baseRes = await this.upload({
filename: shareFile,
content: outputFile.html
})
await this.uploadCss()
if (baseRes) {
await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
frontmatter[this.plugin.settings.yamlField + '_updated'] = moment().format()
frontmatter[this.plugin.settings.yamlField + '_link'] = baseRes + '#' + encryptedData.key
})
}
this.status.hide()
new Notice('File has been shared', 4000)
}
async upload (data: UploadData) {
this.status.setMessage(`Uploading ${data.filename}...`)
return this.plugin.api.upload(data)
}
/**
* Upload local images, encoded as base64
*/
async processImages () {
for (const el of this.dom.querySelectorAll('img')) {
const src = el.getAttribute('src')
if (!src || !src.startsWith('app://')) continue
try {
const srcMatch = src.match(/app:\/\/\w+\/([^?#]+)/)
if (!srcMatch) continue
const localFile = window.decodeURIComponent(srcMatch[1])
const url = (await hash(this.plugin.settings.uid + localFile)) + '.' + localFile.split('.').pop()
el.setAttribute('src', url)
el.removeAttribute('alt')
await this.upload({
filename: url,
content: fs.readFileSync(localFile, {
encoding: 'base64'
}),
encoding: 'base64'
})
} catch (e) {
console.log(e)
}
}
}
async uploadCss () {
// Upload theme CSS, unless this file has previously been shared
// To force a CSS re-upload, just remove the `share_link` frontmatter field
if (!this.meta?.frontmatter?.[this.plugin.settings.yamlField + '_link']) {
await this.upload({ filename: this.plugin.settings.uid + '.css', content: this.css })
// Extract any base64 encoded attachments from the CSS.
// Will use the mime-type list above to determine which attachments to extract.
const regex = /url\s*\(\W*data:([^;,]+)[^)]*?base64\s*,\s*([A-Za-z0-9/=+]+).?\)/
for (const attachment of this.css.match(new RegExp(regex, 'g')) || []) {
const match = attachment.match(new RegExp(regex))
if (match) {
// ALlow whitelisted mime-types/extensions only
const extension = this.extensionFromMime(match[1])
if (extension) {
const filename = (await hash(match[2])) + '.' + extension
this.css = this.css.replace(match[0], `url("${filename}")`)
await this.upload({
filename,
content: match[2],
encoding: 'base64'
})
}
}
}
}
}
/**
* Turn a mime-type into an extension. This is also a whitelist of allowed filetypes.
* @param {string} mimeType
* @return {string|undefined}
*/
extensionFromMime (mimeType: string) {
const mimes: { [key: string]: string[] } = {
ttf: ['font/ttf', 'application/x-font-ttf', 'application/x-font-truetype', 'font/truetype'],
otf: ['font/otf', 'application/x-font-opentype'],
woff: ['font/woff', 'application/font-woff', 'application/x-font-woff'],
woff2: ['font/woff2', 'application/font-woff2', 'application/x-font-woff2']
}
return Object.keys(mimes).find(x => mimes[x].includes((mimeType || '').toLowerCase()))
}
}

100
src/settings.ts Normal file
View file

@ -0,0 +1,100 @@
import { App, Notice, PluginSettingTab, requestUrl, Setting } from 'obsidian'
import SharePlugin from './main'
import { hash } from './crypto'
export interface ShareSettings {
uid: string;
email: string;
apiKey: string;
yamlField: string;
noteWidth: string;
showFooter: boolean;
removeYaml: boolean;
}
export const DEFAULT_SETTINGS: ShareSettings = {
uid: '',
email: '',
apiKey: '',
yamlField: 'share',
noteWidth: '700px',
showFooter: true,
removeYaml: true
}
export class ShareSettingsTab extends PluginSettingTab {
plugin: SharePlugin
constructor (app: App, plugin: SharePlugin) {
super(app, plugin)
this.plugin = plugin
}
display (): void {
const { containerEl } = this
containerEl.empty()
// Email address
new Setting(containerEl)
.setName('Email address')
.setDesc('This is not stored on the Share server, it is solely used to send you an API key.')
.addText(text => text
.setValue(this.plugin.settings.email)
.onChange(async (value) => {
this.plugin.settings.email = value
// Store a hashed value of the email. This is what we use to communicate with the server.
this.plugin.settings.uid = await hash(value)
await this.plugin.saveSettings()
}))
.addButton(btn => btn
.setButtonText('Request API key')
.setCta()
.onClick(async () => {
if (this.plugin.settings.email) {
new Notice('An API key has been sent to ' + this.plugin.settings.email, 3000)
await this.plugin.api.post('/signup', {
email: this.plugin.settings.email
})
}
}))
// API key
new Setting(containerEl)
.setName('API key')
.setDesc('Enter the key which was sent to you via email.')
.addText(text => text
.setValue(this.plugin.settings.apiKey)
.onChange(async (value) => {
this.plugin.settings.apiKey = value
await this.plugin.saveSettings()
}))
// Strip frontmatter
new Setting(containerEl)
.setName('Remove frontmatter/YAML')
.setDesc('Remove frontmatter/YAML/properties from the shared note')
.addToggle(toggle => {
toggle
.setValue(this.plugin.settings.removeYaml)
.onChange(async (value) => {
this.plugin.settings.removeYaml = value
await this.plugin.saveSettings()
this.display()
})
})
// Show/hide the footer
new Setting(containerEl)
.setName('Show the footer')
.addToggle(toggle => {
toggle
.setValue(this.plugin.settings.showFooter)
.onChange(async (value) => {
this.plugin.settings.showFooter = value
await this.plugin.saveSettings()
this.display()
})
})
}
}

130
src/template.ts Normal file
View file

@ -0,0 +1,130 @@
export const Placeholder = {
title: 'TEMPLATE_TITLE',
css: 'TEMPLATE_STYLESHEET',
noteWidth: 'TEMPLATE_WIDTH',
bodyClass: 'TEMPLATE_BODY_CLASS',
bodyStyle: 'TEMPLATE_BODY_STYLE',
previewViewClass: 'TEMPLATE_PREVIEW_VIEW_CLASS',
payload: 'TEMPLATE_ENCRYPTED_DATA',
footer: 'TEMPLATE_FOOTER'
}
const html = `
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="TEMPLATE_STYLESHEET">
<style>
html, body {
overflow: visible !important;
}
.view-content {
height: 100% !important;
}
.reading-view-extra {
max-width: TEMPLATE_WIDTH;
margin: 0 auto;
}
</style>
</head>
<body>
<body class="TEMPLATE_BODY_CLASS" style="TEMPLATE_BODY_STYLE">
<div class="app-container">
<div class="horizontal-main-container">
<div class="workspace">
<div class="workspace-split mod-vertical mod-root">
<div class="workspace-leaf mod-active">
<div class="workspace-leaf-content">
<div class="view-content">
<div class="markdown-reading-view reading-view-extra">
<div class="TEMPLATE_PREVIEW_VIEW_CLASS">
<div id="template-user-data"
class="markdown-preview-sizer markdown-preview-section">
<!-- Note content will be injected here -->
Encrypted note
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
TEMPLATE_FOOTER
</div>
<div id="encrypted-data" style="display: none;">
TEMPLATE_ENCRYPTED_DATA
</div>
<script>
function base64ToArrayBuffer (base64) {
const binaryString = atob(base64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes.buffer
}
async function decryptString ({ ciphertext, iv }, secret) {
const ciphertextBuf = base64ToArrayBuffer(ciphertext)
const ivBuf = base64ToArrayBuffer(iv)
const aesKey = await window.crypto.subtle.importKey('raw', base64ToArrayBuffer(secret), {
name: 'AES-GCM',
length: 256
}, false, ['decrypt'])
const plaintext = await window.crypto.subtle
.decrypt({ name: 'AES-GCM', iv: ivBuf }, aesKey, ciphertextBuf)
return new TextDecoder().decode(plaintext)
}
/*
* Decrypt the original note content
*/
const payload = JSON.parse(document.getElementById('encrypted-data').innerText)
const secret = window.location.hash.slice(1) // Taken from the URL # parameter
if (secret) {
decryptString({ ciphertext: payload.ciphertext, iv: payload.iv }, secret)
.then(text => {
// Inject the user's data
const data = JSON.parse(text)
const contentEl = document.getElementById('template-user-data')
if (contentEl) contentEl.innerHTML = data.content
document.title = data.basename
})
.catch(() => {
const contentEl = document.getElementById('template-user-data')
if (contentEl) contentEl.innerHTML = 'Unable to decrypt using this key.'
})
}
</script>
</body>
</html>
`
export const defaultFooter = `
<div id="footer" class="status-bar">
<div class="status-bar-item">
<span class="status-bar-item-segment">Published with <a
href="https://obsidianshare.com/" target="_blank">Obsidian Share</a></span>
</div>
</div>`
export default class Template {
html: string
footer: string
constructor () {
this.html = html
}
set (key: string, value: string) {
this.html = this.html.replace(new RegExp(key, 'g'), value)
}
}

25
tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"baseUrl": "src",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"dom.iterable",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
}

View file

@ -1,21 +0,0 @@
<?php
$whitelist = ['html', 'css', 'jpg', 'png', 'ttf', 'otf', 'woff', 'woff2'];
$secret = 'some_fancy_secret';
$data = json_decode(file_get_contents('php://input'));
if (! hash_equals($data->auth, hash('sha256', $data->nonce . $secret))) {
http_response_code(404);
exit();
}
$file = explode('.', $data->filename);
$file[0] = preg_replace("/[^a-z0-9]/", '', $file[0]);
if (count($file) === 2 && in_array($file[1], $whitelist) && ! empty($file[0])) {
if ($data->encoding === 'base64') {
// Decode uploaded images
$data->content = base64_decode($data->content);
}
file_put_contents(__DIR__ . "/$file[0].$file[1]", $data->content);
}

14
version-bump.mjs Normal file
View file

@ -0,0 +1,14 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"0.1.0": "0.15.0"
}