Fix security bugs and migrate image uploads to /api/v1/attachments
* Replace /api/v1/resources with /api/v1/attachments for image uploads
* Upload attachments as JSON with base64-encoded content field
* After memo creation, link each attachment via PATCH /api/v1/attachments/{id}
* Rewrite markdown image URLs to use /file/attachments/{id} pattern
* Fix XSS: sanitize marked.parse output with a DOM-based allowlist sanitizer
* Fix SSRF: validate img.src scheme (http/https only) before fetching
* Fix stack overflow: use chunked base64 encoding for large images
* Update CLAUDE.md to document new attachment flow
78
CLAUDE.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**Memos Clipper** is a Chrome Extension (Manifest V3) that clips web pages or text selections and saves them to a [Memos](https://usememos.com) instance in Markdown format.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three-part Chrome extension:
|
||||||
|
|
||||||
|
- **`src/content.js`** — Content script injected into pages; extracts HTML and converts it to Markdown
|
||||||
|
- **`src/popup.html` / `src/popup.js`** — Main popup UI: editor, preview, image gallery, send button
|
||||||
|
- **`src/settings.html` / `src/settings.js`** — Options page: Memos URL, API token, defaults
|
||||||
|
- **`src/background.js`** — Service worker (minimal; onInstalled listener only)
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: Chrome MV3 (Manifest V3)
|
||||||
|
- **Language**: Vanilla JavaScript (ES6+), no frontend framework
|
||||||
|
- **Styling**: Tailwind CSS v4 + PostCSS + Autoprefixer; CSS variables for theming; dark mode via `prefers-color-scheme`
|
||||||
|
- **Markdown**: Custom HTML→Markdown converter in `content.js`; `marked.min.js` for preview rendering
|
||||||
|
- **Build**: Vite v8 with `vite-plugin-static-copy`
|
||||||
|
|
||||||
|
## Build & Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # Install dependencies
|
||||||
|
npm run dev # Dev server with hot reload
|
||||||
|
npm run build # Production build → dist/
|
||||||
|
npm run preview # Preview built output
|
||||||
|
```
|
||||||
|
|
||||||
|
Load the extension in Chrome: **Extensions → Load unpacked → select `dist/`**
|
||||||
|
|
||||||
|
## Key Implementation Details
|
||||||
|
|
||||||
|
### API Integration
|
||||||
|
- Memos API v1: `/api/v1/memos`, `/api/v1/attachments`
|
||||||
|
- Requires Memos v0.22+
|
||||||
|
- Bearer token auth via `chrome.storage.sync`
|
||||||
|
- Attachment flow: upload via `POST /api/v1/attachments` (JSON + base64 `content`), create memo, then link each attachment to the memo via `PATCH /api/v1/attachments/{id}` with `{ memo: "memos/{id}" }`
|
||||||
|
|
||||||
|
### Content Extraction
|
||||||
|
- Removes boilerplate: nav, ads, sidebars, cookie banners (45+ selectors)
|
||||||
|
- Supports full-page and selection-only modes
|
||||||
|
- Converts: headings, lists, tables, code blocks, links, images, blockquotes
|
||||||
|
|
||||||
|
### Image Handling
|
||||||
|
- Filters images smaller than 32px (icons/tracking pixels)
|
||||||
|
- Deduplicates images
|
||||||
|
- Supports data URIs
|
||||||
|
- Uploads images as attachments (`POST /api/v1/attachments`) with base64-encoded content
|
||||||
|
- After memo creation, links each attachment to the memo via `PATCH /api/v1/attachments/{id}`
|
||||||
|
- Attachment file URL pattern: `{memosUrl}/file/attachments/{id}`
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
- `chrome.storage.sync` for cross-device settings (URL, token, defaults)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── manifest.json # MV3 config (permissions, entry points)
|
||||||
|
├── popup.html/.js/.css # Main extension popup
|
||||||
|
├── settings.html/.js/.css # Options page
|
||||||
|
├── content.js # Page content extractor
|
||||||
|
├── background.js # Service worker
|
||||||
|
├── marked.min.js # Bundled Markdown renderer
|
||||||
|
└── icons/ # 16, 32, 48, 128px PNG icons
|
||||||
|
dist/ # Build output (gitignored)
|
||||||
|
vite.config.js
|
||||||
|
package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
- `activeTab`, `scripting`, `storage`
|
||||||
|
- Host permissions: `<all_urls>` (needed for cross-origin image fetching and API calls)
|
||||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 454 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 5.2 KiB |
@@ -15,14 +15,14 @@
|
|||||||
"default_popup": "popup.html",
|
"default_popup": "popup.html",
|
||||||
"default_icon": {
|
"default_icon": {
|
||||||
"16": "icons/icon16.png",
|
"16": "icons/icon16.png",
|
||||||
"32": "icons/logo.svg",
|
"32": "icons/icon32.png",
|
||||||
"48": "icons/icon48.png",
|
"48": "icons/icon48.png",
|
||||||
"128": "icons/icon128.png"
|
"128": "icons/icon128.png"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon16.png",
|
"16": "icons/icon16.png",
|
||||||
"32": "icons/logo.svg",
|
"32": "icons/icon32.png",
|
||||||
"48": "icons/icon48.png",
|
"48": "icons/icon48.png",
|
||||||
"128": "icons/icon128.png"
|
"128": "icons/icon128.png"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,23 +3,37 @@
|
|||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f0f11;
|
--bg: #ffffff;
|
||||||
--surface: #1a1a1f;
|
--surface: #ffffff;
|
||||||
--surface2: #22222a;
|
--surface2: #f9fafb;
|
||||||
--border: #2a2a35;
|
--border: #f3f4f6;
|
||||||
--accent: #10b981;
|
--accent: #10b981;
|
||||||
--accent-dim: #10b98120;
|
--accent-dim: #10b98120;
|
||||||
--accent-hover: #059669;
|
--accent-hover: #059669;
|
||||||
--text: #e8e8f0;
|
--text: #111827;
|
||||||
--text-dim: #777788;
|
--text-dim: #4b5563;
|
||||||
--text-muted: #444455;
|
--text-muted: #9ca3af;
|
||||||
--success: #4ade80;
|
--success: #10b981;
|
||||||
--error: #f87171;
|
--error: #ef4444;
|
||||||
--radius: 6px;
|
--radius: 6px;
|
||||||
--font: 'IBM Plex Sans', sans-serif;
|
--font: 'IBM Plex Sans', sans-serif;
|
||||||
--mono: 'IBM Plex Mono', monospace;
|
--mono: 'IBM Plex Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0f0f11;
|
||||||
|
--surface: #1a1a1f;
|
||||||
|
--surface2: #22222a;
|
||||||
|
--border: #2a2a35;
|
||||||
|
--text: #e8e8f0;
|
||||||
|
--text-dim: #777788;
|
||||||
|
--text-muted: #444455;
|
||||||
|
--success: #4ade80;
|
||||||
|
--error: #f87171;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
width: 440px;
|
width: 440px;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
|
|||||||
@@ -28,16 +28,10 @@
|
|||||||
|
|
||||||
<!-- ── Main editor ── -->
|
<!-- ── Main editor ── -->
|
||||||
<div id="view-main" class="view hidden">
|
<div id="view-main" class="view hidden">
|
||||||
<header class="flex items-center justify-between p-3 border-b border-gray-100 bg-white sticky top-0 z-10">
|
<header class="flex items-center justify-between p-3 border-b border-gray-100 dark:border-gray-800 bg-white dark:bg-[#1a1a1f] sticky top-0 z-10">
|
||||||
<div class="logo flex items-center space-x-2 text-emerald-500">
|
<div class="logo flex items-center space-x-2 text-emerald-500">
|
||||||
<svg viewBox="0 0 32 32" fill="none" width="18" height="18" xmlns="http://www.w3.org/2000/svg">
|
<img src="icons/icon32.png" width="18" height="18" alt="Memos Logo">
|
||||||
<rect width="32" height="32" rx="8" fill="currentColor"/>
|
<span id="page-title" class="page-title font-semibold text-gray-800 dark:text-gray-200 truncate max-w-[180px]">Clip to Memos</span>
|
||||||
<path d="M11 9C11 8.44772 11.4477 8 12 8H20C20.5523 8 21 8.44772 21 9V23C21 23.5523 20.5523 24 20 24H12C11.4477 24 11 23.5523 11 23V9Z" fill="white"/>
|
|
||||||
<path d="M14 12H18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<path d="M14 15H18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<path d="M14 18H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<span id="page-title" class="page-title font-semibold text-gray-800 truncate max-w-[180px]">Clip to Memos</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions flex space-x-1">
|
<div class="header-actions flex space-x-1">
|
||||||
<button id="mode-toggle" class="icon-btn p-1.5 rounded hover:bg-gray-100 text-gray-500 transition" title="Switch clip mode">
|
<button id="mode-toggle" class="icon-btn p-1.5 rounded hover:bg-gray-100 text-gray-500 transition" title="Switch clip mode">
|
||||||
@@ -62,14 +56,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- edit panel -->
|
<!-- edit panel -->
|
||||||
<div id="tab-edit" class="tab-panel flex flex-col h-96 border-b border-gray-100">
|
<div id="tab-edit" class="tab-panel flex flex-col h-96 border-b border-gray-100 dark:border-gray-800">
|
||||||
<textarea rows="14" id="md-editor" spellcheck="false" placeholder="Markdown content…" class="flex-1 w-full p-3 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-emerald-100"></textarea>
|
<textarea rows="14" id="md-editor" spellcheck="false" placeholder="Markdown content…" class="flex-1 w-full p-3 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-emerald-100 dark:focus:ring-emerald-900/30 bg-transparent text-inherit"></textarea>
|
||||||
<div id="char-counter" class="char-counter text-right px-3 py-1 text-[10px] text-gray-400">0 chars</div>
|
<div id="char-counter" class="char-counter text-right px-3 py-1 text-[10px] text-gray-400">0 chars</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- preview panel -->
|
<!-- preview panel -->
|
||||||
<div id="tab-preview" class="tab-panel hidden h-64 overflow-y-auto p-3 border-b border-gray-100">
|
<div id="tab-preview" class="tab-panel hidden h-64 overflow-y-auto p-3 border-b border-gray-100 dark:border-gray-800">
|
||||||
<div id="md-preview" class="preview-body prose prose-sm max-w-none"></div>
|
<div id="md-preview" class="preview-body prose prose-sm dark:prose-invert max-w-none"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- images section -->
|
<!-- images section -->
|
||||||
@@ -90,16 +84,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- footer -->
|
<!-- footer -->
|
||||||
<footer class="flex items-center justify-between p-3 bg-gray-50">
|
<footer class="flex items-center justify-between p-3 bg-gray-50 dark:bg-[#22222a]">
|
||||||
<div class="footer-left">
|
<div class="footer-left">
|
||||||
<select id="visibility-select" class="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-emerald-400 transition">
|
<select id="visibility-select" class="text-xs border border-gray-200 dark:border-gray-700 rounded px-2 py-1 bg-white dark:bg-[#1a1a1f] text-inherit focus:outline-none focus:ring-1 focus:ring-emerald-400 transition">
|
||||||
<option value="PRIVATE">🔒 Private</option>
|
<option value="PRIVATE">🔒 Private</option>
|
||||||
<option value="PROTECTED">🔗 Protected</option>
|
<option value="PROTECTED">🔗 Protected</option>
|
||||||
<option value="PUBLIC">🌐 Public</option>
|
<option value="PUBLIC">🌐 Public</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-right flex space-x-2">
|
<div class="footer-right flex space-x-2">
|
||||||
<button id="reload-btn" class="secondary-btn p-1.5 border border-gray-200 rounded hover:bg-white transition text-gray-500" title="Re-clip page">↺</button>
|
<button id="reload-btn" class="secondary-btn p-1.5 border border-gray-200 dark:border-gray-700 rounded hover:bg-white dark:hover:bg-gray-800 transition text-gray-500" title="Re-clip page">↺</button>
|
||||||
<button id="send-btn" class="send-btn bg-emerald-500 hover:bg-emerald-600 text-white px-4 py-1.5 rounded shadow flex items-center space-x-2 transition">
|
<button id="send-btn" class="send-btn bg-emerald-500 hover:bg-emerald-600 text-white px-4 py-1.5 rounded shadow flex items-center space-x-2 transition">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
||||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||||
|
|||||||
177
src/popup.js
@@ -124,10 +124,54 @@ document.querySelectorAll(".tab").forEach((btn) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Preview ───────────────────────────────────────────────────────────────────
|
// ── Preview ───────────────────────────────────────────────────────────────────
|
||||||
|
function sanitizeHtml(html) {
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
const allowed = new Set([
|
||||||
|
"p","br","b","strong","i","em","u","s","del","ins","code","pre","blockquote",
|
||||||
|
"h1","h2","h3","h4","h5","h6","ul","ol","li","a","img","table","thead",
|
||||||
|
"tbody","tr","th","td","hr","span","div",
|
||||||
|
]);
|
||||||
|
const allowedAttrs = {
|
||||||
|
a: ["href","title"],
|
||||||
|
img: ["src","alt","title","width","height"],
|
||||||
|
td: ["colspan","rowspan"], th: ["colspan","rowspan"],
|
||||||
|
code:["class"], pre: ["class"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function clean(node) {
|
||||||
|
for (const child of [...node.childNodes]) {
|
||||||
|
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const tag = child.tagName.toLowerCase();
|
||||||
|
if (!allowed.has(tag)) {
|
||||||
|
// Replace disallowed element with its text content
|
||||||
|
child.replaceWith(document.createTextNode(child.textContent));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Strip disallowed attributes
|
||||||
|
for (const attr of [...child.attributes]) {
|
||||||
|
const ok = (allowedAttrs[tag] || []).includes(attr.name);
|
||||||
|
if (!ok) { child.removeAttribute(attr.name); continue; }
|
||||||
|
// Block javascript: and data: in href/src
|
||||||
|
if (attr.name === "href" || attr.name === "src") {
|
||||||
|
const v = attr.value.trim().toLowerCase();
|
||||||
|
if (v.startsWith("javascript:") || v.startsWith("data:text")) {
|
||||||
|
child.removeAttribute(attr.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clean(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clean(doc.body);
|
||||||
|
return doc.body.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function renderPreview() {
|
function renderPreview() {
|
||||||
state.markdown = mdEditor.value;
|
state.markdown = mdEditor.value;
|
||||||
if (typeof marked !== "undefined") {
|
if (typeof marked !== "undefined") {
|
||||||
mdPreview.innerHTML = marked.parse(state.markdown, { breaks: true });
|
mdPreview.innerHTML = sanitizeHtml(marked.parse(state.markdown, { breaks: true }));
|
||||||
} else {
|
} else {
|
||||||
// Fallback: basic escaping
|
// Fallback: basic escaping
|
||||||
mdPreview.innerHTML = `<pre style="white-space:pre-wrap">${escHtml(state.markdown)}</pre>`;
|
mdPreview.innerHTML = `<pre style="white-space:pre-wrap">${escHtml(state.markdown)}</pre>`;
|
||||||
@@ -251,23 +295,16 @@ sendBtn.addEventListener("click", async () => {
|
|||||||
sendBtn.disabled = true;
|
sendBtn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let resourceNames = [];
|
// 1. Upload images as attachments if requested (without memo link yet)
|
||||||
|
const imageMap = new Map(); // originalUrl -> attachment name ("attachments/{id}")
|
||||||
// 1. Upload images if requested
|
|
||||||
const imageMap = new Map(); // originalUrl -> resourceName
|
|
||||||
if (attachCheck.checked) {
|
if (attachCheck.checked) {
|
||||||
const toUpload = state.images.filter((img) => img.keep);
|
const toUpload = state.images.filter((img) => img.keep);
|
||||||
let uploaded = 0;
|
let uploaded = 0;
|
||||||
for (const img of toUpload) {
|
for (const img of toUpload) {
|
||||||
sendBtn.textContent = `Uploading image ${uploaded + 1}/${toUpload.length}…`;
|
sendBtn.textContent = `Uploading image ${uploaded + 1}/${toUpload.length}…`;
|
||||||
try {
|
try {
|
||||||
const resource = await uploadImage(baseUrl, token, img);
|
const attachment = await uploadAttachment(baseUrl, token, img);
|
||||||
if (resource) {
|
imageMap.set(img.src, attachment.name);
|
||||||
// v0.22+ uses resource.name; older APIs return resource.id
|
|
||||||
const name = resource.name || `resources/${resource.id}`;
|
|
||||||
resourceNames.push(name);
|
|
||||||
imageMap.set(img.src, name);
|
|
||||||
}
|
|
||||||
uploaded++;
|
uploaded++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to upload image:", img.src, e.message);
|
console.warn("Failed to upload image:", img.src, e.message);
|
||||||
@@ -277,44 +314,28 @@ sendBtn.addEventListener("click", async () => {
|
|||||||
|
|
||||||
sendBtn.textContent = "Creating memo…";
|
sendBtn.textContent = "Creating memo…";
|
||||||
|
|
||||||
// 2. Create memo
|
// 2. Replace original image URLs in markdown with attachment external links
|
||||||
// Try to replace original image URLs with local attachment URLs in the markdown
|
|
||||||
let contentWithImages = finalContent;
|
let contentWithImages = finalContent;
|
||||||
for (const [origUrl, resName] of imageMap.entries()) {
|
for (const [origUrl, attachName] of imageMap.entries()) {
|
||||||
const urlPart = resName.startsWith("resources/") ? resName : `resources/${resName}`;
|
const attachmentUrl = `${baseUrl}/file/${attachName}`;
|
||||||
const attachmentUrl = `${baseUrl}/file/${urlPart}`;
|
|
||||||
// Escape for regex
|
|
||||||
const escapedUrl = origUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedUrl = origUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const re = new RegExp(`!\\[(.*?)\\]\\(${escapedUrl}\\)`, 'g');
|
const re = new RegExp(`!\\[(.*?)\\]\\(${escapedUrl}\\)`, 'g');
|
||||||
contentWithImages = contentWithImages.replace(re, ``);
|
contentWithImages = contentWithImages.replace(re, ``);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append any uploaded images that weren't already in the markdown (e.g. from gallery)
|
// Append any uploaded attachments not already referenced in the markdown
|
||||||
for (const resName of imageMap.values()) {
|
for (const attachName of imageMap.values()) {
|
||||||
const urlPart = resName.startsWith("resources/") ? resName : `resources/${resName}`;
|
const attachmentUrl = `${baseUrl}/file/${attachName}`;
|
||||||
if (!contentWithImages.includes(`/file/${urlPart}`)) {
|
if (!contentWithImages.includes(attachmentUrl)) {
|
||||||
contentWithImages += `\n\n`;
|
contentWithImages += `\n\n`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Create the memo
|
||||||
const body = {
|
const body = {
|
||||||
content: contentWithImages,
|
content: contentWithImages,
|
||||||
visibility,
|
visibility,
|
||||||
};
|
};
|
||||||
// Also set resources array for native attachment display (v0.22+)
|
|
||||||
if (resourceNames.length) {
|
|
||||||
body.resources = resourceNames.map((name) => {
|
|
||||||
const norm = name.startsWith("resources/") ? name : `resources/${name}`;
|
|
||||||
return { name: norm };
|
|
||||||
});
|
|
||||||
// Backward compatibility: some versions use resourceIdList (array of ints)
|
|
||||||
const resourceIds = resourceNames
|
|
||||||
.map(n => parseInt(n.replace("resources/", "")))
|
|
||||||
.filter(id => !isNaN(id));
|
|
||||||
if (resourceIds.length === resourceNames.length) {
|
|
||||||
body.resourceIdList = resourceIds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/api/v1/memos`, {
|
const res = await fetch(`${baseUrl}/api/v1/memos`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -331,10 +352,28 @@ sendBtn.addEventListener("click", async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const memo = await res.json();
|
const memo = await res.json();
|
||||||
// Handle both v0.22+ (memo.name = "memos/123") and older (memo.id = "123" or memo.uid)
|
// memo.name is "memos/{id}"
|
||||||
|
const memoName = memo.name;
|
||||||
|
|
||||||
|
// 4. Link each attachment to the created memo via PATCH
|
||||||
|
for (const attachName of imageMap.values()) {
|
||||||
|
try {
|
||||||
|
await fetch(`${baseUrl}/api/v1/${attachName}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ memo: memoName }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to link attachment to memo:", attachName, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let memoId = "";
|
let memoId = "";
|
||||||
if (memo.name) {
|
if (memoName) {
|
||||||
memoId = memo.name.replace(/^memos\//, "");
|
memoId = memoName.replace(/^memos\//, "");
|
||||||
} else if (memo.uid) {
|
} else if (memo.uid) {
|
||||||
memoId = memo.uid;
|
memoId = memo.uid;
|
||||||
} else if (memo.id) {
|
} else if (memo.id) {
|
||||||
@@ -352,8 +391,17 @@ sendBtn.addEventListener("click", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Upload a single image resource ────────────────────────────────────────────
|
// ── Upload a single image as an attachment ────────────────────────────────────
|
||||||
async function uploadImage(baseUrl, token, img) {
|
async function uploadAttachment(baseUrl, token, img) {
|
||||||
|
// Validate URL scheme to prevent SSRF via crafted page image URLs
|
||||||
|
if (!img.src.startsWith("data:")) {
|
||||||
|
let parsedUrl;
|
||||||
|
try { parsedUrl = new URL(img.src); } catch (_) { throw new Error(`Invalid image URL`); }
|
||||||
|
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
||||||
|
throw new Error(`Unsupported image URL scheme: ${parsedUrl.protocol}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(img.src);
|
const response = await fetch(img.src);
|
||||||
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
|
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
@@ -361,11 +409,9 @@ async function uploadImage(baseUrl, token, img) {
|
|||||||
// Derive a filename with a valid extension
|
// Derive a filename with a valid extension
|
||||||
let filename = "image";
|
let filename = "image";
|
||||||
if (!img.src.startsWith("data:")) {
|
if (!img.src.startsWith("data:")) {
|
||||||
filename = img.src.split("/").pop().split("?")[0].split("#")[0] || "image";
|
filename = img.src.split("/").pop().split("?")[0].split("#")[0] || "image";
|
||||||
}
|
}
|
||||||
// Strip non-filename characters
|
|
||||||
filename = filename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
|
filename = filename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
|
||||||
// If no extension, infer from MIME type
|
|
||||||
if (!/\.\w{2,5}$/.test(filename)) {
|
if (!/\.\w{2,5}$/.test(filename)) {
|
||||||
const mimeToExt = {
|
const mimeToExt = {
|
||||||
"image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png",
|
"image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png",
|
||||||
@@ -377,21 +423,38 @@ async function uploadImage(baseUrl, token, img) {
|
|||||||
}
|
}
|
||||||
if (!filename || filename === ".") filename = "image.jpg";
|
if (!filename || filename === ".") filename = "image.jpg";
|
||||||
|
|
||||||
const formData = new FormData();
|
// Encode blob as base64 for the JSON body (protobuf bytes → base64 in JSON)
|
||||||
formData.append("file", blob, filename);
|
// Chunked to avoid stack overflow when spreading large Uint8Arrays as arguments
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
const uint8 = new Uint8Array(arrayBuffer);
|
||||||
|
let binary = "";
|
||||||
|
const CHUNK = 8192;
|
||||||
|
for (let i = 0; i < uint8.length; i += CHUNK) {
|
||||||
|
binary += String.fromCharCode(...uint8.subarray(i, i + CHUNK));
|
||||||
|
}
|
||||||
|
const base64 = btoa(binary);
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/api/v1/resources`, {
|
const res = await fetch(`${baseUrl}/api/v1/attachments`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: {
|
||||||
body: formData,
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
filename,
|
||||||
|
type: blob.type || "application/octet-stream",
|
||||||
|
content: base64,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const txt = await res.text();
|
const txt = await res.text();
|
||||||
throw new Error(`Resource upload failed ${res.status}: ${txt.slice(0, 120)}`);
|
throw new Error(`Attachment upload failed ${res.status}: ${txt.slice(0, 120)}`);
|
||||||
}
|
}
|
||||||
const resource = await res.json();
|
|
||||||
// Normalise the returned name — could be "resources/abc", "abc", or an int id
|
const attachment = await res.json();
|
||||||
if (resource.name) return resource;
|
if (!attachment.name) {
|
||||||
if (resource.id) return { ...resource, name: `resources/${resource.id}` };
|
throw new Error("Attachment upload returned unexpected shape: " + JSON.stringify(attachment).slice(0, 80));
|
||||||
throw new Error("Resource upload returned unexpected shape: " + JSON.stringify(resource).slice(0, 80));
|
}
|
||||||
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,32 @@
|
|||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f0f11;
|
--bg: #f9fafb;
|
||||||
--surface: #1a1a1f;
|
--surface: #ffffff;
|
||||||
--border: #2a2a35;
|
--border: #f3f4f6;
|
||||||
--accent: #10b981;
|
--accent: #10b981;
|
||||||
--accent-dim: #10b98130;
|
--accent-dim: #10b98130;
|
||||||
--text: #e8e8f0;
|
--text: #111827;
|
||||||
--text-dim: #888899;
|
--text-dim: #4b5563;
|
||||||
--success: #4ade80;
|
--success: #10b981;
|
||||||
--error: #f87171;
|
--error: #ef4444;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--font: 'IBM Plex Sans', sans-serif;
|
--font: 'IBM Plex Sans', sans-serif;
|
||||||
--mono: 'IBM Plex Mono', monospace;
|
--mono: 'IBM Plex Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0f0f11;
|
||||||
|
--surface: #1a1a1f;
|
||||||
|
--border: #2a2a35;
|
||||||
|
--text: #e8e8f0;
|
||||||
|
--text-dim: #888899;
|
||||||
|
--success: #4ade80;
|
||||||
|
--error: #f87171;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|||||||
@@ -8,64 +8,58 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<header class="p-6 border-b border-gray-100 bg-white">
|
<header class="p-6 border-b border-gray-100 dark:border-gray-800 bg-white dark:bg-[#1a1a1f]">
|
||||||
<div class="logo flex items-center space-x-3 text-2xl font-bold text-emerald-500">
|
<div class="logo flex items-center space-x-3 text-2xl font-bold text-emerald-500">
|
||||||
<svg viewBox="0 0 32 32" fill="none" width="32" height="32" xmlns="http://www.w3.org/2000/svg">
|
<img src="icons/icon48.png" width="32" height="32" alt="Memos Logo">
|
||||||
<rect width="32" height="32" rx="8" fill="currentColor"/>
|
<span class="dark:text-gray-200">Memos Clipper Settings</span>
|
||||||
<path d="M11 9C11 8.44772 11.4477 8 12 8H20C20.5523 8 21 8.44772 21 9V23C21 23.5523 20.5523 24 20 24H12C11.4477 24 11 23.5523 11 23V9Z" fill="white"/>
|
|
||||||
<path d="M14 12H18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<path d="M14 15H18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<path d="M14 18H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<span>Memos Clipper Settings</span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="max-w-3xl mx-auto p-6 space-y-8">
|
<main class="max-w-3xl mx-auto p-6 space-y-8">
|
||||||
<div class="card bg-white rounded-xl shadow-sm border border-gray-100 p-6 space-y-6">
|
<div class="card bg-white dark:bg-[#1a1a1f] rounded-xl shadow-sm border border-gray-100 dark:border-gray-800 p-6 space-y-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-800 border-b pb-2">Connection</h2>
|
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 border-b dark:border-gray-800 pb-2">Connection</h2>
|
||||||
<label class="block space-y-2">
|
<label class="block space-y-2">
|
||||||
<span class="text-sm font-medium text-gray-700">Memos Instance URL</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Memos Instance URL</span>
|
||||||
<input type="url" id="memos-url" placeholder="https://memos.example.com" class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-100 focus:border-emerald-400 outline-none transition" />
|
<input type="url" id="memos-url" placeholder="https://memos.example.com" class="w-full px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-900/30 dark:bg-transparent dark:text-inherit outline-none transition" />
|
||||||
<small class="block text-xs text-gray-400">The base URL of your usememos instance (no trailing slash)</small>
|
<small class="block text-xs text-gray-400 dark:text-gray-500">The base URL of your usememos instance (no trailing slash)</small>
|
||||||
</label>
|
</label>
|
||||||
<label class="block space-y-2">
|
<label class="block space-y-2">
|
||||||
<span class="text-sm font-medium text-gray-700">API Token</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">API Token</span>
|
||||||
<input type="password" id="api-token" placeholder="Your access token" class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-100 focus:border-emerald-400 outline-none transition" />
|
<input type="password" id="api-token" placeholder="Your access token" class="w-full px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-900/30 dark:bg-transparent dark:text-inherit outline-none transition" />
|
||||||
<small class="block text-xs text-gray-400">Settings → Account → Access Tokens in your Memos instance</small>
|
<small class="block text-xs text-gray-400 dark:text-gray-500">Settings → Account → Access Tokens in your Memos instance</small>
|
||||||
</label>
|
</label>
|
||||||
<div class="actions flex space-x-3 pt-2">
|
<div class="actions flex space-x-3 pt-2">
|
||||||
<button id="test-btn" class="secondary flex-1 bg-gray-50 hover:bg-gray-100 text-gray-700 font-medium py-2 px-4 rounded-lg border border-gray-200 transition">Test Connection</button>
|
<button id="test-btn" class="secondary flex-1 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 font-medium py-2 px-4 rounded-lg border border-gray-200 dark:border-gray-700 transition">Test Connection</button>
|
||||||
<button id="save-btn" class="flex-1 bg-emerald-500 hover:bg-emerald-600 text-white font-medium py-2 px-4 rounded-lg shadow-sm transition">Save Settings</button>
|
<button id="save-btn" class="flex-1 bg-emerald-500 hover:bg-emerald-600 text-white font-medium py-2 px-4 rounded-lg shadow-sm transition">Save Settings</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="status" class="status hidden p-3 rounded-lg text-sm"></div>
|
<div id="status" class="status hidden p-3 rounded-lg text-sm"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-white rounded-xl shadow-sm border border-gray-100 p-6 space-y-6">
|
<div class="card bg-white dark:bg-[#1a1a1f] rounded-xl shadow-sm border border-gray-100 dark:border-gray-800 p-6 space-y-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-800 border-b pb-2">Defaults</h2>
|
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 border-b dark:border-gray-800 pb-2">Defaults</h2>
|
||||||
<label class="block space-y-2">
|
<label class="block space-y-2">
|
||||||
<span class="text-sm font-medium text-gray-700">Default visibility</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Default visibility</span>
|
||||||
<select id="visibility" class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-100 focus:border-emerald-400 outline-none bg-white transition">
|
<select id="visibility" class="w-full px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-900/30 bg-white dark:bg-transparent dark:text-inherit outline-none transition">
|
||||||
<option value="PRIVATE">Private</option>
|
<option value="PRIVATE">Private</option>
|
||||||
<option value="PROTECTED">Protected</option>
|
<option value="PROTECTED">Protected</option>
|
||||||
<option value="PUBLIC">Public</option>
|
<option value="PUBLIC">Public</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="block space-y-2">
|
<label class="block space-y-2">
|
||||||
<span class="text-sm font-medium text-gray-700">Default clip mode</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Default clip mode</span>
|
||||||
<select id="clip-mode" class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-100 focus:border-emerald-400 outline-none bg-white transition">
|
<select id="clip-mode" class="w-full px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-900/30 bg-white dark:bg-transparent dark:text-inherit outline-none transition">
|
||||||
<option value="page">Full page (article extraction)</option>
|
<option value="page">Full page (article extraction)</option>
|
||||||
<option value="selection">Selection only</option>
|
<option value="selection">Selection only</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div class="space-y-3 pt-2">
|
<div class="space-y-3 pt-2">
|
||||||
<label class="flex items-center space-x-3 cursor-pointer group">
|
<label class="flex items-center space-x-3 cursor-pointer group">
|
||||||
<input type="checkbox" id="include-images" checked class="w-4 h-4 text-emerald-500 border-gray-300 rounded focus:ring-emerald-400 transition" />
|
<input type="checkbox" id="include-images" checked class="w-4 h-4 text-emerald-500 border-gray-300 dark:border-gray-700 rounded focus:ring-emerald-400 transition" />
|
||||||
<span class="text-sm text-gray-700 group-hover:text-gray-900 transition">Upload page images as attachments</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition">Upload page images as attachments</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center space-x-3 cursor-pointer group">
|
<label class="flex items-center space-x-3 cursor-pointer group">
|
||||||
<input type="checkbox" id="include-tags" class="w-4 h-4 text-emerald-500 border-gray-300 rounded focus:ring-emerald-400 transition" />
|
<input type="checkbox" id="include-tags" class="w-4 h-4 text-emerald-500 border-gray-300 dark:border-gray-700 rounded focus:ring-emerald-400 transition" />
|
||||||
<span class="text-sm text-gray-700 group-hover:text-gray-900 transition">Automatically add #clipped tag</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition">Automatically add #clipped tag</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions pt-4">
|
<div class="actions pt-4">
|
||||||
|
|||||||