diff --git a/.gitignore b/.gitignore index aa0e901..b04b289 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ coverage/ # Optional local tooling .vercel .netlify +skills/watch-comments/state/*.json diff --git a/bin/share/index.ts b/bin/share/index.ts index 7776c0e..701a1b4 100755 --- a/bin/share/index.ts +++ b/bin/share/index.ts @@ -28,8 +28,10 @@ import { SOCKET_PATH } from "../../src/rpc.js"; // ── Constants ───────────────────────────────────────────────────────────────── const DEFAULT_PORT = parseInt(process.env["SHARE_PORT"] ?? "3001"); -const DEFAULT_MINUTES = 10; +const DEFAULT_MINUTES = 5; +const MAX_MINUTES = 20; const DEFAULT_TIMES = 1; +const MAX_TIMES = 3; const DEFAULT_MAX_BYTES = 100 * 1024 * 1024; // 100 MB const CLEANUP_INTERVAL_MS = 60_000; @@ -45,13 +47,13 @@ const MIME: Record = { ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", - ".svg": "image/svg+xml", + ".svg": "application/octet-stream", // SVG can contain JS; force download // Archives ".zip": "application/zip", ".tar": "application/x-tar", ".gz": "application/gzip", // Data / web (non-sensitive formats only) - ".html": "text/html", + ".html": "application/octet-stream", // force download; prevent JS execution in browser ".csv": "text/csv", // Media ".mp4": "video/mp4", @@ -123,7 +125,7 @@ USAGE bun /app/bin/share rm Revoke a link by token prefix or file path ADD FLAGS - --minutes Link lifetime in minutes (default: ${String(DEFAULT_MINUTES)}, max: 60) + --minutes Link lifetime in minutes (default: ${String(DEFAULT_MINUTES)}, max: ${String(MAX_MINUTES)}) --times Max downloads before expiry (default: ${String(DEFAULT_TIMES)}) --delete-after Delete source file after final download --max-size Reject files larger than this (default: 100MB) @@ -139,7 +141,7 @@ EXAMPLES bun /app/bin/share add /tmp/report.pdf --minutes 10 # Share a file 3 times over 30 minutes, delete after last download - bun /app/bin/share add /tmp/data.zip --minutes 30 --times 3 --delete-after + bun /app/bin/share add /tmp/data.zip --minutes 20 --times 3 --delete-after # List active links bun /app/bin/share list @@ -210,6 +212,8 @@ function handleRequest(req: Request): Response { } const token = match[1]!; + + // Single read — used for all checks and the decrement write const store = readStore(); const entry = store[token]; @@ -248,7 +252,9 @@ function handleRequest(req: Request): Response { try { unlinkSync(entry.filePath); } catch { /* best effort */ } } - const fileName = entry.filePath.split("/").pop() ?? "file"; + const rawName = entry.filePath.split("/").pop() ?? "file"; + // Strip quotes and control chars to prevent Content-Disposition header injection + const fileName = rawName.replace(/[\x00-\x1f"\\]/g, "_"); const file = Bun.file(entry.filePath); return new Response(file, { @@ -257,7 +263,6 @@ function handleRequest(req: Request): Response { ...SECURE_HEADERS, "Content-Type": mimeFor(entry.filePath), "Content-Disposition": `attachment; filename="${fileName}"`, - "X-Uses-Remaining": String(entry.usesRemaining), }, }); } @@ -310,13 +315,13 @@ async function cmdAdd(argv: string[]): Promise { process.exit(1); } - if (minutes < 1 || minutes > 60) { - console.error("Error: --minutes must be between 1 and 60"); + if (minutes < 1 || minutes > MAX_MINUTES) { + console.error(`Error: --minutes must be between 1 and ${String(MAX_MINUTES)}`); process.exit(1); } - if (times < 1) { - console.error("Error: --times must be at least 1"); + if (times < 1 || times > MAX_TIMES) { + console.error(`Error: --times must be between 1 and ${String(MAX_TIMES)}`); process.exit(1); } diff --git a/bin/share/store.ts b/bin/share/store.ts index 6a0db09..1f00869 100644 --- a/bin/share/store.ts +++ b/bin/share/store.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync, existsSync, renameSync } from "node:fs"; -export const STORE_PATH = process.env["SHARE_STORE_PATH"] ?? "/data/sharing/store.json"; +export const STORE_PATH = process.env["SHARE_STORE_PATH"] ?? "/tmp/sharing/store.json"; export const ALLOWED_PREFIXES = ["/data/", "/tmp/", "/var/tmp/"]; export interface TokenEntry { diff --git a/skills/gh-comments/SKILL.md b/skills/gh-comments/SKILL.md new file mode 100644 index 0000000..2c2705e --- /dev/null +++ b/skills/gh-comments/SKILL.md @@ -0,0 +1,118 @@ +--- +name: gh-comments +description: > + Use this skill to fetch or watch GitHub comments on issues and PRs. + Triggers include: "get comments in X repo", "watch for comments in X repo", + "any new comments on PR #N?", "keep an eye on issue #N", "start watching + owner/repo", "notify me of new comments". Default mode fetches once and exits; + use --watch to poll continuously. +--- + +# gh-comments Skill + +Fetch new comments once, or watch continuously. State is persisted between runs +so the same comment is never reported twice. + +--- + +## Usage + +```bash +# Fetch new comments once and exit (default) +bun gh-comments.ts + +# Watch continuously, polling every 60s +bun gh-comments.ts --watch + +# Scope to a specific PR or issue +bun gh-comments.ts --pr 42 +bun gh-comments.ts --issue 7 + +# Custom poll interval (watch mode only) +bun gh-comments.ts --watch --interval 120 + +# Show all comments, ignoring state (re-fetch everything seen so far) +bun gh-comments.ts --all +``` + +Always set env vars: +```bash +SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" bun gh-comments.ts ... +``` + +--- + +## Running in the Background (watch mode) + +```bash +SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" \ + bun /app/skills/gh-comments/scripts/gh-comments.ts --watch \ + >> /data/skills/gh-comments/-.log 2>&1 & + +echo $! > /data/skills/gh-comments/-.pid +echo "Watcher started" +``` + +Check on it: +```bash +tail -f /data/skills/gh-comments/-.log +kill -0 $(cat /data/skills/gh-comments/-.pid) && echo running || echo stopped +``` + +Stop it: +```bash +kill $(cat /data/skills/gh-comments/-.pid) +``` + +--- + +## Telegram Notifications + +Set once; persists to env: +```bash +export TELEGRAM_BOT_TOKEN= +export TELEGRAM_CHAT_ID= +``` + +When set, every new comment is also sent as a Telegram message. + +--- + +## What Gets Checked + +For each open issue or PR, all three GitHub comment streams are checked: + +| Stream | Covers | +|---|---| +| `issues/:n/comments` | Top-level thread (issues + PR conversation) | +| `pulls/:n/comments` | Inline review comments | +| `pulls/:n/reviews` | Submitted review bodies | + +--- + +## State Files + +Seen comment IDs are stored per-repo at: +``` +/data/skills/gh-comments/state/-.json +``` + +Reset (re-report all existing comments on next run): +```bash +rm /data/skills/gh-comments/state/-.json +``` + +Or use `--all` for a one-off full fetch without touching the state file. + +--- + +## Options + +| Flag | Default | Description | +|---|---|---| +| `--watch` | — | Poll continuously instead of fetching once | +| `--pr ` | — | Scope to one PR | +| `--issue ` | — | Scope to one issue | +| `--interval ` | `60` | Seconds between polls (watch mode only) | +| `--all` | — | Ignore state; show all comments | +| `--state-dir ` | `/data/skills/gh-comments/state` | Custom state location | diff --git a/skills/gh-comments/scripts/gh-comments.ts b/skills/gh-comments/scripts/gh-comments.ts new file mode 100644 index 0000000..b694f35 --- /dev/null +++ b/skills/gh-comments/scripts/gh-comments.ts @@ -0,0 +1,255 @@ +#!/usr/bin/env bun +/** + * gh-comments + * Fetch or watch GitHub comments on issues and PRs in a repo. + * + * Usage: + * bun gh-comments.ts [options] + * + * Modes: + * (default) Fetch new comments once and exit + * --watch Poll continuously for new comments + * + * Options: + * --pr Scope to a specific PR + * --issue Scope to a specific issue + * --interval Poll interval in watch mode (default: 60) + * --state-dir Where to persist seen IDs + * (default: /data/skills/gh-comments/state) + * --all Include already-seen comments (ignores state) + * + * Env vars required: + * GITHUB_TOKEN GitHub personal access token + * SSL_CERT_FILE Path to CA bundle (e.g. /data/cacert.pem) + * + * Env vars optional: + * TELEGRAM_BOT_TOKEN If set, sends new comments via Telegram + * TELEGRAM_CHAT_ID Required if TELEGRAM_BOT_TOKEN is set + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; + +// ── Args ────────────────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); + +function flag(name: string): boolean { + return args.includes(name); +} +function opt(name: string): string | undefined { + const i = args.indexOf(name); + return i !== -1 ? args[i + 1] : undefined; +} + +const repo = args[0]; +if (!repo || repo.startsWith("--")) { + console.error( + "Usage: bun gh-comments.ts [--watch] [--pr N] [--issue N] [--interval 60] [--all]" + ); + process.exit(1); +} +if (!repo.includes("/")) { + console.error(`Invalid repo "${repo}" — expected owner/repo`); + process.exit(1); +} + +const watchMode = flag("--watch"); +const showAll = flag("--all"); +const intervalSec = parseInt(opt("--interval") ?? "60", 10); +const watchPR = opt("--pr") != null ? parseInt(opt("--pr")!, 10) : null; +const watchIssue = opt("--issue") != null ? parseInt(opt("--issue")!, 10) : null; +const stateDir = opt("--state-dir") ?? "/data/skills/gh-comments/state"; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +if (!GITHUB_TOKEN) { console.error("GITHUB_TOKEN env var is required"); process.exit(1); } + +const SSL_CERT_FILE = process.env.SSL_CERT_FILE ?? "/data/cacert.pem"; +const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; +const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID; +const GH = "/data/gh"; + +// ── State ───────────────────────────────────────────────────────────────────── + +interface SeenCursors { + issueComments: number; + reviewComments: number; + reviews: number; +} +interface State { + seen: Record; +} + +const stateFile = join(stateDir, `${repo.replace("/", "-")}.json`); + +function loadState(): State { + if (showAll) return { seen: {} }; + if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true }); + if (!existsSync(stateFile)) return { seen: {} }; + try { return JSON.parse(readFileSync(stateFile, "utf8")) as State; } + catch { return { seen: {} }; } +} + +function saveState(state: State) { + if (showAll) return; // don't advance cursors when --all is used + if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true }); + writeFileSync(stateFile, JSON.stringify(state, null, 2)); +} + +// ── GitHub API ──────────────────────────────────────────────────────────────── + +async function ghApi(path: string): Promise { + const proc = Bun.spawn([GH, "api", path, "--paginate"], { + env: { ...process.env, GH_TOKEN: GITHUB_TOKEN!, SSL_CERT_FILE }, + stdout: "pipe", + stderr: "pipe", + }); + const out = await new Response(proc.stdout).text(); + const err = await new Response(proc.stderr).text(); + const code = await proc.exited; + if (code !== 0) throw new Error(`gh api ${path} failed (${code}): ${err.trim()}`); + const cleaned = out.trim(); + try { return JSON.parse(cleaned) as T; } + catch { + // --paginate outputs one array per page; join them + const pages = cleaned.split(/\n(?=\[)/).map((p) => JSON.parse(p) as unknown[]); + return pages.flat() as unknown as T; + } +} + +interface GHIssue { number: number; title: string; pull_request?: unknown } +interface GHComment { id: number; user: { login: string }; body: string; html_url: string; created_at: string } +interface GHReview { id: number; user: { login: string }; body: string; html_url: string; state: string; submitted_at: string } + +// ── Output ──────────────────────────────────────────────────────────────────── + +function fmt( + kind: "issue" | "pr", + number: number, + title: string, + type: string, + c: GHComment | GHReview +): string { + const when = "created_at" in c ? c.created_at : (c as GHReview).submitted_at; + const preview = c.body.trim().slice(0, 300) + (c.body.length > 300 ? "…" : ""); + return [ + `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, + `📬 New ${type} on ${kind.toUpperCase()} #${number} in ${repo}`, + ` "${title}"`, + ` by @${c.user.login} at ${new Date(when).toISOString()}`, + ` ${c.html_url}`, + ``, + ` ${preview}`, + ].join("\n"); +} + +async function sendTelegram(text: string) { + if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) return; + const esc = text.replace(/&/g,"&").replace(//g,">"); + await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: TELEGRAM_CHAT_ID, + text: esc, + parse_mode: "HTML", + disable_web_page_preview: true, + }), + }); +} + +// ── Poll one item ───────────────────────────────────────────────────────────── + +async function pollItem( + state: State, + kind: "issue" | "pr", + number: number, + title: string, + out: string[] +) { + const key = `${kind}:${number}`; + const seen: SeenCursors = state.seen[key] ?? { issueComments: 0, reviewComments: 0, reviews: 0 }; + + // (a) Top-level thread comments + const issueComments = await ghApi(`repos/${repo}/issues/${number}/comments`); + const newIC = issueComments.filter((c) => c.id > seen.issueComments); + for (const c of newIC) out.push(fmt(kind, number, title, "comment", c)); + if (newIC.length) seen.issueComments = Math.max(...newIC.map((c) => c.id)); + + if (kind === "pr") { + // (b) Inline review comments + const reviewComments = await ghApi(`repos/${repo}/pulls/${number}/comments`); + const newRC = reviewComments.filter((c) => c.id > seen.reviewComments); + for (const c of newRC) out.push(fmt(kind, number, title, "inline comment", c)); + if (newRC.length) seen.reviewComments = Math.max(...newRC.map((c) => c.id)); + + // (c) Submitted review bodies + const reviews = await ghApi(`repos/${repo}/pulls/${number}/reviews`); + const newRev = reviews.filter((r) => r.id > seen.reviews && r.body?.trim()); + for (const r of newRev) out.push(fmt(kind, number, title, `review (${r.state})`, r)); + if (newRev.length) seen.reviews = Math.max(...newRev.map((r) => r.id)); + } + + state.seen[key] = seen; +} + +// ── Poll ────────────────────────────────────────────────────────────────────── + +async function poll(): Promise { + const state = loadState(); + const found: string[] = []; + const errors: string[] = []; + + if (watchPR !== null) { + try { + const pr = await ghApi<{ title: string }>(`repos/${repo}/pulls/${watchPR}`); + await pollItem(state, "pr", watchPR, pr.title, found); + } catch (e) { errors.push(`PR #${watchPR}: ${e}`); } + } else if (watchIssue !== null) { + try { + const issue = await ghApi<{ title: string }>(`repos/${repo}/issues/${watchIssue}`); + await pollItem(state, "issue", watchIssue, issue.title, found); + } catch (e) { errors.push(`Issue #${watchIssue}: ${e}`); } + } else { + const items = await ghApi(`repos/${repo}/issues?state=open&per_page=100`); + for (const item of items) { + const kind = item.pull_request ? "pr" : "issue"; + try { await pollItem(state, kind, item.number, item.title, found); } + catch (e) { errors.push(`${kind} #${item.number}: ${e}`); } + } + } + + saveState(state); + + for (const msg of found) { + console.log(msg); + await sendTelegram(msg); + } + for (const e of errors) console.error(`[error] ${e}`); + + return found.length; +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +if (watchMode) { + console.log(`👁 Watching ${repo} — polling every ${intervalSec}s`); + if (watchPR !== null) console.log(` Scope: PR #${watchPR} only`); + else if (watchIssue !== null) console.log(` Scope: Issue #${watchIssue} only`); + else console.log(` Scope: all open issues + PRs`); + console.log(` State: ${stateFile}`); + console.log(` Telegram: ${TELEGRAM_BOT_TOKEN ? "enabled" : "disabled"}`); + console.log(); + + await poll(); + setInterval(async () => { + const n = await poll(); + if (n === 0) process.stdout.write(`[${new Date().toISOString()}] No new comments. Next poll in ${intervalSec}s\r`); + }, intervalSec * 1000); +} else { + // Fetch mode: one poll, report count, exit + const n = await poll(); + if (n === 0) console.log("No new comments."); + else console.log(`\n${n} new comment${n === 1 ? "" : "s"}.`); + process.exit(0); +} diff --git a/skills/gh-comments/state/.gitkeep b/skills/gh-comments/state/.gitkeep new file mode 100644 index 0000000..e69de29