diff --git a/projects/diigo-bak/diigo-netlify-rescue-design.md b/projects/diigo-bak/diigo-netlify-rescue-design.md new file mode 100644 index 0000000..e226e77 --- /dev/null +++ b/projects/diigo-bak/diigo-netlify-rescue-design.md @@ -0,0 +1,244 @@ +# Diigo Rescue Service Design (Netlify) + +## Assumption + +We assume Diigo is defunct (domain suspended, service effectively abandoned) and this service is an emergency, good-faith effort to help users recover their own data. + +This is an operational assumption for this rescue effort, not legal advice. + +## Goal + +Provide a short-lived rescue service for former Diigo users who do not have API keys: + +- User enters Diigo username/password. +- Service uses a server-side API key to export all bookmarks. +- User downloads their data (`.ndjson`) immediately. + +## Constraints + +- Diigo domain/API is unstable. +- Users generally do not have personal API keys. +- Shared API key may be non-rotatable. +- Service must minimize risk when handling credentials. + +## Non-Goals + +- Long-term hosted bookmark platform. +- Account management for users. +- Data warehousing of user exports. + +## High-Level Architecture + +1. Static UI (standalone Netlify site) +- Form: username, password. +- "Start export" button. +- Progress display. +- Download links when complete. + +2. Netlify Function API +- `POST /.netlify/functions/export-start` +- `GET /.netlify/functions/export-status?id=...` +- `GET /.netlify/functions/export-download?id=...` + +3. Background export worker +- Netlify Background Function does paged fetch from Diigo API. +- Writes temporary result to Netlify Blobs (or equivalent short-lived storage). +- Stores only job metadata + encrypted result blob + expiry. + +4. Diigo API access +- Server-side secret: `DIIGO_API_KEY`. +- Auth to Diigo with Basic auth from submitted username/password. +- Prefer known working endpoint strategy (host/IP override in runtime code if needed). + +Deployment model: + +- Separate Netlify site, distinct from madmode.com. +- Source rooted under `projects/diigo-bak/rescue-site/`. +- Manual deploy of that subdirectory only. + +## Source Files + +Planned implementation files: + +- `projects/diigo-bak/rescue-site/index.html` + - Standalone public explainer page and UI. +- `projects/diigo-bak/rescue-site/static/js/diigo-rescue.js` + - Browser-side form handling, polling, and download trigger. +- `projects/diigo-bak/rescue-site/netlify/functions/diigo-export-start.js` + - Validate request, enforce limits, create export job. +- `projects/diigo-bak/rescue-site/netlify/functions/diigo-export-status.js` + - Return job status/progress. +- `projects/diigo-bak/rescue-site/netlify/functions/diigo-export-download.js` + - Return NDJSON download for completed job. +- `projects/diigo-bak/rescue-site/netlify/functions/diigo-export-worker.js` + - Background export worker that pages Diigo API and writes NDJSON blob. +- `projects/diigo-bak/rescue-site/netlify/functions/_diigo-client.js` + - Shared Diigo API client and pagination logic. +- `projects/diigo-bak/rescue-site/netlify/functions/_rate-limit.js` + - Per-username limiter and daily cap checks. +- `projects/diigo-bak/rescue-site/netlify/functions/_jobs.js` + - Job metadata and blob storage helpers. +- `projects/diigo-bak/rescue-site/netlify.toml` + - Function configuration, env wiring, and any route redirects. + +## Data Flow + +1. User submits username/password to `export-start`. +2. Service validates anti-abuse checks (captcha/rate limits/token). +3. Service creates job ID and starts background export. +4. Background function pages bookmarks (`start`, `count`) until empty page. +5. Service serializes: +- `bookmarks.ndjson` (one item per line) +6. User polls status endpoint. +7. User downloads files. +8. Job and blobs expire quickly (default: 1 hour). + +## Threat Model + +Primary risks: + +- Credential theft (username/password exposure). +- API key abuse via open endpoint. +- Data leakage between users. +- Service used for brute force attacks. + +Secondary risks: + +- Excessive costs due to abuse. + +## Security Requirements + +1. Secret handling +- Keep `DIIGO_API_KEY` only in Netlify env secrets. +- Never echo or return key. + +2. Credential handling +- Do not log request bodies. +- Keep username/password only in memory for outbound API calls. +- Zero references after use. +- Do not persist credentials in blobs or metadata. + +3. Endpoint protection +- Per-username rate limits. +- Global concurrency cap. +- Netlify platform protections are the fallback for broader abuse events. + +4. Data isolation +- Random, unguessable job IDs (>=128 bits). +- Download endpoints require signed, short-lived token. +- Jobs hard-expire and are deleted automatically. + +5. Observability without secrets +- Log only: timestamp, hashed username, job state, item counts, error class. +- No raw credentials, no raw Diigo URLs with secrets. + +6. Operational controls +- Kill switch env var (`RESCUE_ENABLED=false`). +- Max runtime per job. +- Max bookmark count guard. +- Explicit service sunset date with auto-disable. + +7. Mandatory short window +- Default operating window is 3 days. +- Service must refuse new jobs after sunset. +- Re-enable requires manual config change to a new sunset date. + +## API Sketch + +### `POST /export-start` + +Request JSON: + +```json +{ + "username": "alice", + "password": "secret" +} +``` + +Response: + +```json +{ + "jobId": "j_...", + "statusUrl": "/.netlify/functions/export-status?id=j_..." +} +``` + +### `GET /export-status?id=...` + +Response: + +```json +{ + "state": "queued|running|done|error|expired", + "fetched": 1200, + "message": "optional" +} +``` + +### `GET /export-download?id=...` + +Response headers: + +- `Content-Type: application/x-ndjson` +- `Content-Disposition: attachment; filename="diigo-bookmarks.ndjson"` + +## Runtime Strategy for Unstable DNS/TLS + +Implement a small transport abstraction: + +- Primary: call `https://secure.diigo.com/api/v2/...`. +- Fallback: known-good host/IP strategy if primary fails. +- Surface clear error messages when Diigo backend is unavailable. + +Note: exact fallback mechanics depend on runtime network capabilities. + +## UX Notes + +- Clear warning: "You are trusting this rescue service with your Diigo password." +- Explicit retention policy shown in UI. +- Show estimated export duration and current page count. +- Offer "Download NDJSON" first (stream-friendly). + +## Rollout Plan + +1. Private alpha (invite-only, 3-5 users). +2. Limited beta (strict daily cap: 50 export starts/day total). + +Beyond limited beta is intentionally out of scope for this plan and will +be decided after beta results. + +## Feedback Channel + +Collect operator/user feedback in: + +- https://github.com/dckc/madmode-blog/issues/32 + +## Sunset Policy + +- Config: + - `RESCUE_START_AT` (UTC timestamp) + - `RESCUE_SUNSET_AT` (UTC timestamp) +- Constraint: + - `RESCUE_SUNSET_AT - RESCUE_START_AT <= 72h` +- Runtime behavior: + - If current time is past `RESCUE_SUNSET_AT`, all `export-start` requests return `410 Gone`. + - Existing incomplete jobs are cancelled. +- Manual renewal rule: + - Service does not auto-extend. + - Operator must deploy a new config with a fresh sunset timestamp. + +## Exit Plan + +- Disable via kill switch. +- Revoke/remove API key if possible. +- Delete all retained blobs and job metadata. +- Keep only aggregate, non-sensitive metrics. + +## Open Questions + +1. Is shared-key usage compliant with Diigo API terms? +2. What Netlify runtime networking features are available for host/IP fallback? +3. What is acceptable maximum per-user export size/time? +4. How long should completed exports remain downloadable (15m, 1h, 24h)? diff --git a/projects/diigo-bak/rescue-site/README.md b/projects/diigo-bak/rescue-site/README.md new file mode 100644 index 0000000..f4fce06 --- /dev/null +++ b/projects/diigo-bak/rescue-site/README.md @@ -0,0 +1,28 @@ +# Diigo Rescue Site + +Standalone Netlify site for emergency Diigo bookmark export. + +## Required Environment Variables + +- `DIIGO_API_KEY` +- `RESCUE_START_AT` (UTC timestamp) +- `RESCUE_SUNSET_AT` (UTC timestamp, <= 72h after start) + +Example values (for this week): + +- `RESCUE_START_AT=2026-03-07T23:00:00Z` (`2026-03-07 17:00` America/Chicago) +- `RESCUE_SUNSET_AT=2026-03-10T22:00:00Z` (`2026-03-10 17:00` America/Chicago) + +## Optional Environment Variables + +- `RESCUE_ENABLED` (`true`/`false`, default `true`) +- `DIIGO_KNOWN_IP` (default `54.148.192.94`) +- `MAX_ACTIVE_JOBS` (default `3`) +- `MAX_GLOBAL_STARTS_PER_DAY` (default `50`) +- `MAX_USER_STARTS_PER_DAY` (default `5`) +- `MAX_DOWNLOAD_AGE_MS` (default `3600000`) + +## Local Notes + +This subproject is separate from the main blog build. Deploy this directory +as its own Netlify site. diff --git a/projects/diigo-bak/rescue-site/index.html b/projects/diigo-bak/rescue-site/index.html new file mode 100644 index 0000000..3dfd069 --- /dev/null +++ b/projects/diigo-bak/rescue-site/index.html @@ -0,0 +1,80 @@ + + +
+ + ++ This service is intended for emergency data recovery from a defunct Diigo + service. By using it, you trust this service with your Diigo credentials. +
+ + + +Idle.+ +
Data is appended as each page arrives. You can copy it at any point.
+ + ++ Feedback / View Source: + PR #245 +
+ + + + diff --git a/projects/diigo-bak/rescue-site/netlify.toml b/projects/diigo-bak/rescue-site/netlify.toml new file mode 100644 index 0000000..3602e27 --- /dev/null +++ b/projects/diigo-bak/rescue-site/netlify.toml @@ -0,0 +1,7 @@ +[build] + publish = "." + command = "echo rescue-site" + +[functions] + directory = "netlify/functions" + node_bundler = "esbuild" diff --git a/projects/diigo-bak/rescue-site/netlify/functions/_config.js b/projects/diigo-bak/rescue-site/netlify/functions/_config.js new file mode 100644 index 0000000..8b24ec1 --- /dev/null +++ b/projects/diigo-bak/rescue-site/netlify/functions/_config.js @@ -0,0 +1,68 @@ +function parseTs(name) { + const raw = process.env[name]; + if (!raw) { + return null; + } + const ms = Date.parse(raw); + if (Number.isNaN(ms)) { + return null; + } + return ms; +} + +function utcDay(ms) { + return new Date(ms).toISOString().slice(0, 10); +} + +function getConfig(now = Date.now()) { + return { + enabled: process.env.RESCUE_ENABLED !== "false", + startAt: parseTs("RESCUE_START_AT"), + sunsetAt: parseTs("RESCUE_SUNSET_AT"), + diigoApiKey: process.env.DIIGO_API_KEY || "", + knownIp: process.env.DIIGO_KNOWN_IP || "54.148.192.94", + maxActiveJobs: Number(process.env.MAX_ACTIVE_JOBS || "3"), + maxGlobalStartsPerDay: Number(process.env.MAX_GLOBAL_STARTS_PER_DAY || "50"), + maxUserStartsPerDay: Number(process.env.MAX_USER_STARTS_PER_DAY || "5"), + maxDownloadAgeMs: Number( + process.env.MAX_DOWNLOAD_AGE_MS || String(60 * 60 * 1000), + ), + now, + dayKey: utcDay(now), + }; +} + +function checkWindow(cfg) { + if (!cfg.enabled) { + return { ok: false, status: 503, message: "service disabled" }; + } + if (!cfg.diigoApiKey) { + return { ok: false, status: 503, message: "server is not configured" }; + } + if (cfg.startAt === null || cfg.sunsetAt === null) { + return { + ok: false, + status: 503, + message: "start/sunset timestamps are not configured", + }; + } + if (cfg.sunsetAt - cfg.startAt > 72 * 60 * 60 * 1000) { + return { + ok: false, + status: 503, + message: "invalid window: must be 72h or less", + }; + } + if (cfg.now < cfg.startAt) { + return { ok: false, status: 403, message: "service not open yet" }; + } + if (cfg.now >= cfg.sunsetAt) { + return { ok: false, status: 410, message: "service window has ended" }; + } + return { ok: true }; +} + +module.exports = { + getConfig, + checkWindow, +}; diff --git a/projects/diigo-bak/rescue-site/netlify/functions/_diigo-client.js b/projects/diigo-bak/rescue-site/netlify/functions/_diigo-client.js new file mode 100644 index 0000000..a937b25 --- /dev/null +++ b/projects/diigo-bak/rescue-site/netlify/functions/_diigo-client.js @@ -0,0 +1,142 @@ +const https = require("https"); +const { URLSearchParams } = require("url"); + +const API_HOST = "secure.diigo.com"; +const API_BASE = "https://secure.diigo.com/api/v2"; + +function authHeader(username, password) { + const token = Buffer.from(username + ":" + password, "utf8").toString("base64"); + return "Basic " + token; +} + +async function requestPrimary(path, headers) { + const res = await fetch(API_BASE + path, { method: "GET", headers }); + const text = await res.text(); + return { + status: res.status, + body: text, + }; +} + +function requestKnownIp(path, headers, knownIp) { + return new Promise((resolve, reject) => { + const req = https.request( + { + host: knownIp, + port: 443, + method: "GET", + path: "/api/v2" + path, + servername: API_HOST, + headers: Object.assign({}, headers, { Host: API_HOST }), + }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + resolve({ status: res.statusCode || 0, body: data }); + }); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +function parseArray(body) { + let parsed; + try { + parsed = JSON.parse(body); + } catch { + if (typeof body === "string" && body.trim().startsWith("<")) { + throw new Error("Diigo returned HTML instead of JSON"); + } + throw new Error("Invalid JSON from Diigo API"); + } + if (!Array.isArray(parsed)) { + throw new Error("unexpected Diigo response shape"); + } + return parsed; +} + +async function requestPage({ + username, + password, + apiKey, + start, + count, + knownIp, +}) { + const params = new URLSearchParams({ + user: username, + sort: "2", + filter: "all", + start: String(start), + count: String(count), + key: apiKey, + }); + const path = "/bookmarks?" + params.toString(); + const headers = { + Authorization: authHeader(username, password), + Accept: "application/json", + }; + + try { + const primary = await requestPrimary(path, headers); + if (primary.status === 200) { + return parseArray(primary.body); + } + if (primary.status === 401) { + throw new Error("authentication failed"); + } + } catch (err) { + // Fall through to known-ip strategy. + if (!knownIp) { + throw err; + } + } + + if (!knownIp) { + throw new Error("Diigo API unavailable"); + } + const fallback = await requestKnownIp(path, headers, knownIp); + if (fallback.status === 200) { + return parseArray(fallback.body); + } + if (fallback.status === 401) { + throw new Error("authentication failed"); + } + throw new Error("Diigo API unavailable: HTTP " + String(fallback.status)); +} + +async function* fetchAllBookmarks({ + username, + password, + apiKey, + knownIp, + pageSize = 100, +}) { + let start = 0; + for (;;) { + const page = await requestPage({ + username, + password, + apiKey, + start, + count: pageSize, + knownIp, + }); + if (page.length === 0) { + return; + } + yield page; + start += pageSize; + } +} + +module.exports = { + requestPage, + fetchAllBookmarks, +}; diff --git a/projects/diigo-bak/rescue-site/netlify/functions/_export-runner.js b/projects/diigo-bak/rescue-site/netlify/functions/_export-runner.js new file mode 100644 index 0000000..c6c1292 --- /dev/null +++ b/projects/diigo-bak/rescue-site/netlify/functions/_export-runner.js @@ -0,0 +1,40 @@ +const { fetchAllBookmarks } = require("./_diigo-client"); +const { updateJob } = require("./_jobs"); + +async function runExportJob({ jobId, username, password, apiKey, knownIp }) { + updateJob(jobId, { state: "running", error: null }); + const lines = []; + let fetched = 0; + try { + for await (const page of fetchAllBookmarks({ + username, + password, + apiKey, + knownIp, + })) { + for (const item of page) { + lines.push(JSON.stringify(item)); + } + fetched += page.length; + updateJob(jobId, { fetched }); + } + updateJob(jobId, { + state: "done", + fetched, + ndjson: lines.join("\n") + (lines.length ? "\n" : ""), + }); + } catch (err) { + updateJob(jobId, { + state: "error", + error: String(err && err.message ? err.message : err), + }); + } finally { + // Scrub local references. + username = ""; + password = ""; + } +} + +module.exports = { + runExportJob, +}; diff --git a/projects/diigo-bak/rescue-site/netlify/functions/_jobs.js b/projects/diigo-bak/rescue-site/netlify/functions/_jobs.js new file mode 100644 index 0000000..5b2f55b --- /dev/null +++ b/projects/diigo-bak/rescue-site/netlify/functions/_jobs.js @@ -0,0 +1,68 @@ +const crypto = require("crypto"); + +const jobs = new Map(); + +function makeJobId() { + return "j_" + crypto.randomBytes(16).toString("hex"); +} + +function usernameHash(username) { + return crypto.createHash("sha256").update(username).digest("hex").slice(0, 16); +} + +function createJob({ usernameHashValue, now }) { + const id = makeJobId(); + const job = { + id, + state: "queued", + fetched: 0, + createdAt: now, + updatedAt: now, + usernameHash: usernameHashValue, + ndjson: "", + error: null, + }; + jobs.set(id, job); + return job; +} + +function getJob(id) { + return jobs.get(id) || null; +} + +function updateJob(id, patch) { + const job = jobs.get(id); + if (!job) { + return null; + } + Object.assign(job, patch, { updatedAt: Date.now() }); + jobs.set(id, job); + return job; +} + +function countActiveJobs() { + let n = 0; + for (const job of jobs.values()) { + if (job.state === "queued" || job.state === "running") { + n += 1; + } + } + return n; +} + +function purgeOldJobs(maxAgeMs, now = Date.now()) { + for (const [id, job] of jobs.entries()) { + if (now - job.createdAt > maxAgeMs) { + jobs.delete(id); + } + } +} + +module.exports = { + usernameHash, + createJob, + getJob, + updateJob, + countActiveJobs, + purgeOldJobs, +}; diff --git a/projects/diigo-bak/rescue-site/netlify/functions/_rate-limit.js b/projects/diigo-bak/rescue-site/netlify/functions/_rate-limit.js new file mode 100644 index 0000000..4e47d41 --- /dev/null +++ b/projects/diigo-bak/rescue-site/netlify/functions/_rate-limit.js @@ -0,0 +1,49 @@ +const perUserByDay = new Map(); +const globalByDay = new Map(); + +function _key(dayKey, username) { + return dayKey + "|" + username.toLowerCase(); +} + +function _bump(map, key) { + const n = (map.get(key) || 0) + 1; + map.set(key, n); + return n; +} + +function _read(map, key) { + return map.get(key) || 0; +} + +function checkAndConsume({ dayKey, username, maxUserStartsPerDay, maxGlobalStartsPerDay }) { + const userKey = _key(dayKey, username); + const gKey = dayKey; + + const userCount = _read(perUserByDay, userKey); + if (userCount >= maxUserStartsPerDay) { + return { + ok: false, + status: 429, + message: "daily per-user export limit reached", + userCount, + }; + } + + const globalCount = _read(globalByDay, gKey); + if (globalCount >= maxGlobalStartsPerDay) { + return { + ok: false, + status: 429, + message: "daily service cap reached", + globalCount, + }; + } + + _bump(perUserByDay, userKey); + _bump(globalByDay, gKey); + return { ok: true }; +} + +module.exports = { + checkAndConsume, +}; diff --git a/projects/diigo-bak/rescue-site/netlify/functions/diigo-export-download.js b/projects/diigo-bak/rescue-site/netlify/functions/diigo-export-download.js new file mode 100644 index 0000000..5519a30 --- /dev/null +++ b/projects/diigo-bak/rescue-site/netlify/functions/diigo-export-download.js @@ -0,0 +1,84 @@ +const { getConfig, checkWindow } = require("./_config"); +const { checkAndConsume } = require("./_rate-limit"); +const { fetchAllBookmarks } = require("./_diigo-client"); + +exports.handler = async (event) => { + const cfg = getConfig(); + if (event.httpMethod !== "POST") { + return { + statusCode: 405, + headers: { "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify({ error: "method not allowed" }), + }; + } + const windowCheck = checkWindow(cfg); + if (!windowCheck.ok) { + return { + statusCode: windowCheck.status, + headers: { "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify({ error: windowCheck.message }), + }; + } + + let body; + try { + body = JSON.parse(event.body || "{}"); + } catch { + body = {}; + } + const username = String(body.username || "").trim(); + const password = String(body.password || ""); + if (!username || !password) { + return { + statusCode: 400, + headers: { "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify({ error: "username and password are required" }), + }; + } + + const limit = checkAndConsume({ + dayKey: cfg.dayKey, + username, + maxUserStartsPerDay: cfg.maxUserStartsPerDay, + maxGlobalStartsPerDay: cfg.maxGlobalStartsPerDay, + }); + if (!limit.ok) { + return { + statusCode: limit.status, + headers: { "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify({ error: limit.message }), + }; + } + + const lines = []; + try { + for await (const page of fetchAllBookmarks({ + username, + password, + apiKey: cfg.diigoApiKey, + knownIp: cfg.knownIp, + })) { + for (const item of page) { + lines.push(JSON.stringify(item)); + } + } + } catch (err) { + return { + statusCode: 502, + headers: { "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + error: String(err && err.message ? err.message : err), + }), + }; + } + + return { + statusCode: 200, + headers: { + "content-type": "application/x-ndjson; charset=utf-8", + "content-disposition": 'attachment; filename="diigo-bookmarks.ndjson"', + "cache-control": "no-store", + }, + body: lines.join("\n") + (lines.length ? "\n" : ""), + }; +}; diff --git a/projects/diigo-bak/rescue-site/netlify/functions/diigo-export-start.js b/projects/diigo-bak/rescue-site/netlify/functions/diigo-export-start.js new file mode 100644 index 0000000..94cfc4f --- /dev/null +++ b/projects/diigo-bak/rescue-site/netlify/functions/diigo-export-start.js @@ -0,0 +1,75 @@ +const { getConfig, checkWindow } = require("./_config"); +const { + createJob, + countActiveJobs, + purgeOldJobs, + usernameHash, +} = require("./_jobs"); +const { checkAndConsume } = require("./_rate-limit"); +const { runExportJob } = require("./_export-runner"); + +function json(statusCode, body) { + return { + statusCode, + headers: { "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify(body), + }; +} + +exports.handler = async (event) => { + if (event.httpMethod !== "POST") { + return json(405, { error: "method not allowed" }); + } + const cfg = getConfig(); + const windowCheck = checkWindow(cfg); + if (!windowCheck.ok) { + return json(windowCheck.status, { error: windowCheck.message }); + } + + purgeOldJobs(cfg.maxDownloadAgeMs); + if (countActiveJobs() >= cfg.maxActiveJobs) { + return json(429, { error: "too many active exports; try later" }); + } + + let body; + try { + body = JSON.parse(event.body || "{}"); + } catch { + return json(400, { error: "invalid JSON body" }); + } + const username = String(body.username || "").trim(); + const password = String(body.password || ""); + if (!username || !password) { + return json(400, { error: "username and password are required" }); + } + + const limit = checkAndConsume({ + dayKey: cfg.dayKey, + username, + maxUserStartsPerDay: cfg.maxUserStartsPerDay, + maxGlobalStartsPerDay: cfg.maxGlobalStartsPerDay, + }); + if (!limit.ok) { + return json(limit.status, { error: limit.message }); + } + + const job = createJob({ + usernameHashValue: usernameHash(username), + now: cfg.now, + }); + + runExportJob({ + jobId: job.id, + username, + password, + apiKey: cfg.diigoApiKey, + knownIp: cfg.knownIp, + }); + + return json(202, { + jobId: job.id, + state: job.state, + statusUrl: "/.netlify/functions/diigo-export-status?id=" + job.id, + downloadUrl: "/.netlify/functions/diigo-export-download?id=" + job.id, + }); +}; diff --git a/projects/diigo-bak/rescue-site/netlify/functions/diigo-export-status.js b/projects/diigo-bak/rescue-site/netlify/functions/diigo-export-status.js new file mode 100644 index 0000000..f4a3d5d --- /dev/null +++ b/projects/diigo-bak/rescue-site/netlify/functions/diigo-export-status.js @@ -0,0 +1,43 @@ +const { getConfig, checkWindow } = require("./_config"); +const { getJob, purgeOldJobs } = require("./_jobs"); + +function json(statusCode, body) { + return { + statusCode, + headers: { "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify(body), + }; +} + +exports.handler = async (event) => { + const cfg = getConfig(); + purgeOldJobs(cfg.maxDownloadAgeMs); + + const id = event.queryStringParameters && event.queryStringParameters.id; + if (!id) { + return json(400, { error: "missing id" }); + } + const job = getJob(id); + if (!job) { + return json(404, { error: "job not found or expired" }); + } + + const windowCheck = checkWindow(cfg); + if (!windowCheck.ok && job.state !== "done") { + return json(windowCheck.status, { + id: job.id, + state: "expired", + fetched: job.fetched, + error: windowCheck.message, + }); + } + + return json(200, { + id: job.id, + state: job.state, + fetched: job.fetched, + error: job.error, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + }); +}; diff --git a/projects/diigo-bak/rescue-site/netlify/functions/diigo-export-worker.js b/projects/diigo-bak/rescue-site/netlify/functions/diigo-export-worker.js new file mode 100644 index 0000000..338a382 --- /dev/null +++ b/projects/diigo-bak/rescue-site/netlify/functions/diigo-export-worker.js @@ -0,0 +1,10 @@ +exports.handler = async () => { + return { + statusCode: 501, + headers: { "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + error: "not implemented", + note: "Current build runs export work directly from export-start.", + }), + }; +}; diff --git a/projects/diigo-bak/rescue-site/static/js/diigo-rescue.js b/projects/diigo-bak/rescue-site/static/js/diigo-rescue.js new file mode 100644 index 0000000..82d5711 --- /dev/null +++ b/projects/diigo-bak/rescue-site/static/js/diigo-rescue.js @@ -0,0 +1,53 @@ +(function () { + const form = document.getElementById("export-form"); + const statusEl = document.getElementById("status"); + const outputEl = document.getElementById("output"); + + const setStatus = (obj) => { + statusEl.textContent = + typeof obj === "string" ? obj : JSON.stringify(obj, null, 2); + }; + + form.addEventListener("submit", async (ev) => { + ev.preventDefault(); + outputEl.value = ""; + + const username = document.getElementById("username").value.trim(); + const password = document.getElementById("password").value; + const count = 100; + let start = 0; + let pageNo = 0; + let total = 0; + setStatus("Starting export..."); + + try { + for (;;) { + pageNo += 1; + setStatus( + "Fetching page " + pageNo + " starting from record " + (start + 1), + ); + const res = await fetch("/.netlify/functions/diigo-export-page", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username, password, start, count }), + }); + const body = await res.json(); + if (!res.ok) { + setStatus(body); + return; + } + if (body.ndjson) { + outputEl.value += body.ndjson; + } + total += body.fetched || 0; + if (body.done) { + setStatus("Export complete. Total records: " + total); + return; + } + start = body.nextStart; + } + } catch (err) { + setStatus({ error: String(err) }); + } + }); +})();