Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions functions/api/share/[[id]].js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
const SHARE_TTL_SECONDS = 60 * 60 * 24 * 90;
const MAX_CONTENT_CHARS = 500000;
const SHARE_ID_LENGTH = 10;
const SHARE_ID_PATTERN = /^[a-z2-9]{6,20}$/;
const SHARE_ID_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";

function jsonResponse(body, init) {
const headers = new Headers(init && init.headers);
headers.set("Content-Type", "application/json; charset=utf-8");
headers.set("Cache-Control", "no-store");
headers.set("Access-Control-Allow-Origin", "*");
headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
headers.set("Access-Control-Allow-Headers", "Content-Type");

return new Response(JSON.stringify(body), {
status: init && init.status ? init.status : 200,
headers
});
}

function emptyResponse(init) {
const headers = new Headers(init && init.headers);
headers.set("Access-Control-Allow-Origin", "*");
headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
headers.set("Access-Control-Allow-Headers", "Content-Type");

return new Response(null, {
status: init && init.status ? init.status : 204,
headers
});
}

function getShareId(params) {
const raw = params && params.id;
if (Array.isArray(raw)) return raw.join("/");
return typeof raw === "string" ? raw : "";
}

function generateShareId(length = SHARE_ID_LENGTH) {
const bytes = crypto.getRandomValues(new Uint8Array(length));
let id = "";
for (let i = 0; i < bytes.length; i += 1) {
id += SHARE_ID_ALPHABET[bytes[i] % SHARE_ID_ALPHABET.length];
}
return id;
}

async function createUniqueShareId(kv) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const id = generateShareId();
const existing = await kv.get(id);
if (!existing) return id;
}
throw new Error("Unable to allocate share id");
}

function sanitizeMode(mode) {
return mode === "edit" ? "edit" : "view";
}

function sanitizeTitle(title) {
return String(title || "").trim().replace(/\s+/g, " ").slice(0, 120);
}

export async function onRequest({ request, env, params }) {
if (request.method === "OPTIONS") {
return emptyResponse({ status: 204 });
}

if (!env || !env.SHARE_KV) {
return jsonResponse({ error: "SHARE_KV binding is not configured" }, { status: 503 });
}

const id = getShareId(params);

if (request.method === "POST" && !id) {
let body;
try {
body = await request.json();
} catch (_) {
return jsonResponse({ error: "invalid json" }, { status: 400 });
}

const content = body && body.content;
if (typeof content !== "string" || content.length === 0) {
return jsonResponse({ error: "content required" }, { status: 400 });
}
if (content.length > MAX_CONTENT_CHARS) {
return jsonResponse({ error: "content too large" }, { status: 413 });
}

const shareId = await createUniqueShareId(env.SHARE_KV);
const record = {
content,
mode: sanitizeMode(body.mode),
title: sanitizeTitle(body.title),
createdAt: Date.now(),
size: content.length
};

await env.SHARE_KV.put(shareId, JSON.stringify(record), {
expirationTtl: SHARE_TTL_SECONDS
});

return jsonResponse({
id: shareId,
expiresIn: SHARE_TTL_SECONDS
}, { status: 201 });
}

if (request.method === "GET" && id) {
if (!SHARE_ID_PATTERN.test(id)) {
return jsonResponse({ error: "invalid id" }, { status: 400 });
}

const raw = await env.SHARE_KV.get(id);
if (!raw) {
return jsonResponse({ error: "not found" }, { status: 404 });
}

let record;
try {
record = JSON.parse(raw);
} catch (_) {
return jsonResponse({ error: "invalid record" }, { status: 500 });
}

return jsonResponse({
content: record.content || "",
mode: sanitizeMode(record.mode),
title: sanitizeTitle(record.title),
createdAt: record.createdAt || null,
size: record.size || (record.content ? record.content.length : 0)
});
}

return jsonResponse({ error: "not found" }, { status: 404 });
}
36 changes: 36 additions & 0 deletions functions/live-room/[[room]].js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export async function onRequest(context) {
const { request, env, params } = context;
const upgradeHeader = request.headers.get("Upgrade") || "";

if (upgradeHeader.toLowerCase() !== "websocket") {
return new Response("Markdown Viewer live room endpoint", {
headers: {
"Cache-Control": "no-store",
"Content-Type": "text/plain; charset=utf-8"
}
});
}

if (!env || !env.LIVE_ROOMS) {
return new Response("Missing LIVE_ROOMS Durable Object binding", {
status: 503,
headers: {
"Cache-Control": "no-store",
"Content-Type": "text/plain; charset=utf-8"
}
});
}

const roomParam = Array.isArray(params.room) ? params.room.join("/") : params.room;
const roomName = String(roomParam || "").trim();
if (!roomName || roomName.length > 160) {
return new Response("Invalid live room", { status: 400 });
}
const secret = new URL(request.url).searchParams.get("secret") || "";
if (!secret || secret.length > 256) {
return new Response("Invalid live room credentials", { status: 400 });
}

const id = env.LIVE_ROOMS.idFromName(roomName + ":" + secret);
return env.LIVE_ROOMS.get(id).fetch(request);
}
76 changes: 69 additions & 7 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; base-uri 'self'; object-src 'none'; script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net 'sha256-DgMFO4QE+qqf2xNgeNb5gMKG6BtiiQFniYj21c88yME='; worker-src 'self'; connect-src 'self' https://api.github.com https://raw.githubusercontent.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://kroki.io https://www.plantuml.com https://mermaid.ink https://paulrosen.github.io; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; font-src 'self' data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; media-src 'self' blob: data:; manifest-src 'self'; upgrade-insecure-requests">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; base-uri 'self'; object-src 'none'; script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh 'sha256-DgMFO4QE+qqf2xNgeNb5gMKG6BtiiQFniYj21c88yME='; worker-src 'self'; connect-src 'self' ws: wss: https://api.github.com https://raw.githubusercontent.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://kroki.io https://www.plantuml.com https://mermaid.ink https://paulrosen.github.io; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; font-src 'self' data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; media-src 'self' blob: data:; manifest-src 'self'; upgrade-insecure-requests">
<!-- DNS Prefetch & Preconnect CDN Origins to Warm Up Latency -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
Expand Down Expand Up @@ -180,9 +180,14 @@ <h1 class="h4 mb-0 me-2">Markdown Viewer</h1>
<i class="bi bi-clipboard"></i> <span class="btn-text">Copy</span>
</button>

<button id="share-button" class="tool-button" title="Share via URL">
<i class="bi bi-share"></i> <span class="btn-text">Share</span>
<button id="share-button" class="tool-button" title="Share a snapshot URL">
<i class="bi bi-share"></i> <span class="btn-text">Share Snapshot</span>
</button>

<button id="live-share-button" class="tool-button" title="Start temporary live collaboration">
<i class="bi bi-broadcast"></i> <span class="btn-text">Live Share</span>
</button>
<div id="live-share-toolbar-participants" class="live-share-toolbar-participants" aria-label="Live Share participants" hidden></div>

<div class="dropdown me-1">
<button class="tool-button dropdown-toggle" type="button" id="languageDropdown" data-bs-toggle="dropdown" aria-expanded="false" title="Switch Language">
Expand Down Expand Up @@ -302,8 +307,12 @@ <h2 class="h5 m-0">Menu</h2>
<i class="bi bi-clipboard me-2"></i> Copy
</button>

<button id="mobile-share-button" class="mobile-menu-item" title="Share via URL">
<i class="bi bi-share me-2"></i> Share
<button id="mobile-share-button" class="mobile-menu-item" title="Share a snapshot URL">
<i class="bi bi-share me-2"></i> Share Snapshot
</button>

<button id="mobile-live-share-button" class="mobile-menu-item" title="Start temporary live collaboration">
<i class="bi bi-broadcast me-2"></i> Live Share
</button>

<div class="mobile-menu-item dropdown w-100 p-0 border-0">
Expand Down Expand Up @@ -706,7 +715,7 @@ <h3 class="modal-section-title">Open-source credits</h3>
</button>
</div>
<div class="modal-body">
<p class="share-modal-description">Choose how recipients can interact with this document.</p>
<p class="share-modal-description">Choose how recipients can open this snapshot.</p>
<div class="share-mode-cards">
<label class="share-mode-card" id="share-card-view" for="share-mode-view">
<input type="radio" id="share-mode-view" name="share-mode" value="view" checked />
Expand All @@ -733,14 +742,66 @@ <h3 class="modal-section-title">Open-source credits</h3>
<i class="bi bi-clipboard"></i>
</button>
</div>
<p class="share-modal-notice"><i class="bi bi-info-circle"></i> The entire document is encoded in the URL. No data is sent to any server.</p>
<p class="share-modal-notice"><i class="bi bi-info-circle"></i> Small snapshots stay encoded in the URL. Larger snapshots use a short Cloudflare KV link that expires after 90 days.</p>
</div>
<div class="reset-modal-actions">
<button class="reset-modal-btn reset-modal-cancel" id="share-modal-close">Close</button>
</div>
</div>
</div>

<!-- Live Share Modal -->
<div id="live-share-modal" class="reset-modal-overlay modal-overlay" role="dialog" aria-modal="true" aria-labelledby="live-share-modal-title" aria-hidden="true" style="display:none;">
<div class="reset-modal-box reset-modal-box--wide modal-box">
<div class="modal-header">
<p id="live-share-modal-title" class="reset-modal-message">Live Share</p>
<button type="button" class="modal-close-btn" id="live-share-modal-close-icon" aria-label="Close live share dialog">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<p class="share-modal-description">Start a temporary Cloudflare live room for the active Markdown tab.</p>
<div class="reset-modal-field">
<label class="reset-modal-label" for="live-share-display-name">Display name</label>
<input type="text" id="live-share-display-name" class="rename-modal-input" maxlength="40" autocomplete="off" placeholder="Fresh Tomato" />
</div>
<div class="live-share-status" id="live-share-status" aria-live="polite">
<span class="live-share-status-dot" aria-hidden="true"></span>
<span id="live-share-status-text">No live room active</span>
</div>
<div class="live-share-participants" id="live-share-participants" aria-label="Live collaborators"></div>
<div class="share-url-row live-share-link-row">
<input type="text" id="live-share-url-input" class="rename-modal-input share-url-input" readonly placeholder="Start a session to create an invite link" aria-label="Live Share invite link" />
<button class="reset-modal-btn share-copy-btn" id="live-share-copy-btn" title="Copy invite link" disabled>
<i class="bi bi-clipboard"></i>
</button>
</div>
<p class="share-modal-notice"><i class="bi bi-info-circle"></i> Live rooms are relayed in memory. Everyone with the live link can edit while the room is active.</p>
</div>
<div class="reset-modal-actions">
<button class="reset-modal-btn reset-modal-cancel" id="live-share-end-btn" disabled>End live session</button>
<button class="reset-modal-btn" id="live-share-start-btn">Start session</button>
</div>
</div>
</div>

<div id="live-share-expired-modal" class="reset-modal-overlay modal-overlay" role="dialog" aria-modal="true" aria-labelledby="live-share-expired-title" aria-hidden="true" style="display:none;">
<div class="reset-modal-box reset-modal-box--wide modal-box">
<div class="modal-header">
<p id="live-share-expired-title" class="reset-modal-message">Live Share room expired</p>
<button type="button" class="modal-close-btn" id="live-share-expired-close-icon" aria-label="Close expired live share dialog">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<p id="live-share-expired-message" class="share-modal-description">This Live Share room has ended or is no longer active.</p>
</div>
<div class="reset-modal-actions">
<button class="reset-modal-btn reset-modal-cancel" id="live-share-expired-close">Close</button>
</div>
</div>
</div>

<!-- Rename Modal -->
<div id="rename-modal" class="reset-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="rename-modal-title" aria-hidden="true" style="display:none;">
<div class="reset-modal-box reset-modal-box--wide">
Expand Down Expand Up @@ -976,6 +1037,7 @@ <h3 class="modal-section-title">Open-source credits</h3>
<div class="editor-pane is-loading">
<div id="line-numbers" class="line-numbers" aria-hidden="true" inert></div>
<div id="editor-highlight-layer" class="editor-highlight-layer" aria-hidden="true" tabindex="-1" inert></div>
<div id="live-cursors-layer" class="live-cursors-layer" aria-hidden="true" inert></div>
<div class="editor-skeleton" id="editor-skeleton" aria-hidden="true">
<div class="skeleton-placeholder skeleton-title"></div>
<div class="skeleton-placeholder skeleton-line skeleton-w90"></div>
Expand Down
Loading
Loading