mirror of
https://github.com/jvsteiner/send-note.git
synced 2026-07-22 11:20:26 +00:00
Rebuilt the codebase as a plugin
This commit is contained in:
parent
80be4c652a
commit
7e2fd3a3a3
29 changed files with 4386 additions and 453 deletions
3
.eslintignore
Normal file
3
.eslintignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
|
||||
main.js
|
||||
25
.eslintrc
Normal file
25
.eslintrc
Normal 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
23
.gitignore
vendored
|
|
@ -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
0
.hotreload
Normal file
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
39
README.md
39
README.md
|
|
@ -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
48
esbuild.config.mjs
Normal 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 |
|
|
@ -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
11
manifest.json
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
3520
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
25
package.json
Normal file
25
package.json
Normal 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
42
src/api.ts
Normal 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
152
src/crypto.ts
Normal 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
38
src/main.ts
Normal 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
220
src/note.ts
Normal 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
100
src/settings.ts
Normal 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
130
src/template.ts
Normal 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
25
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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
14
version-bump.mjs
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.1.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue