diff --git a/.gitignore b/.gitignore index 1b9851d..d8183e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ .env +.browser-auth.json .DS_Store tsconfig.tsbuildinfo *.pem diff --git a/AGENTS.md b/AGENTS.md index be384db..25ac902 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,17 +20,38 @@ This document captures developer-focused guidelines and conventions, and is VERY - `src/test/setup.ts` loads automatically via Vitest to expose browser-like globals (localStorage, fetch, atob/btoa). Keep new hook/UI tests compatible with that environment instead of redefining globals in each file. - `src/test/mock-remote.ts` is a GitHub REST stub that powers sync-heavy tests (e.g. `useRepoData` flows). Prefer it when you need to exercise `syncBidirectional` without network calls. +### E2E / Browser Testing + +When shipping a UI feature, verify the full flow end-to-end in the browser using `npx agent-browser` before calling it done. This catches wiring and UI bugs that unit tests won't catch. + +Typical workflow: + +1. The dev server must be running (`npm start` — the human developer usually has this up). +2. **First run requires auth**: open headed, ask the user to sign in, then save state so future runs are headless: + ```bash + npx agent-browser --headed open http://localhost:3000 + # ask user to sign in, then: + npx agent-browser state save .browser-auth.json + ``` + The file is gitignored and persists across restarts — you only need to do this once. +3. **Subsequent runs**: load saved state and test headlessly: + ```bash + npx agent-browser state load .browser-auth.json + npx agent-browser open http://localhost:3000 + ``` +4. Always clean up: `npx agent-browser close` + +The dev frontend talks to the **production backend** (see `VITE_VIBENOTE_API_BASE` in `.env`), so e2e tests exercise real API flows — shares get committed to GitHub, sync hits the real server, etc. + ### Commit Conventions - Do NOT commit unless asked. Even if you were asked to commit within a session, don't commit more changes in the same session without being asked. -### Details you might not need +### Other dev notes -- Run the frontend: `npm start` (Vite on `http://localhost:3000`) - - Human developer will have this running. He can provide feedback on UI changes and test changes manually if necessary. -- (Typically not needed) Run the backend: `npm run server:start` - - We usually set `VITE_VIBENOTE_API_BASE` to the _production backend_ when developing the frontend, so we don't have to start the backend. -- Env: Human developer manually fills `.env` with GitHub App variables. Coding agents MUST NEVER read or write `.env`, actual secrets may be stored there, even in development. +- Run the frontend: `npm start` (Vite on `http://localhost:3000`). The human developer usually has this running. +- Run the backend: `npm run server:start` — typically not needed since `VITE_VIBENOTE_API_BASE` points to the production backend. +- **IMPORTANT — Env**: The human developer manually fills `.env` with GitHub App secrets. Coding agents MUST NEVER read or write `.env`, even in development. - Node runs TypeScript directly (2025+): use `node path/to/file.ts` for quick scripts; no ts-node/tsx needed. ## Product and architecture @@ -88,11 +109,3 @@ See `.env.example` and `docs/AUTH.md` for a detailed breakdown of variables and - example: do NOT use `useMemo()` when the logic just filters an array - React: Do not use `useCallback()` (unless you have a strong reason). Just recreating callbacks on every render is usually fast, and will ensure they are never stale. - Prefer putting dense logic into simple top-level `function`s, instead of creating spaghetti of inline declared callbacks that implicitly depend on in-scope variables. - -## NO GAMBIARRA POLICY - ASK FOR FEEDBACK INSTEAD - -This is meant to be a high-quality, production codebase maintained over a long time horizon and solving difficult problems by careful engineering. In order to maintain quality, we must strive to keep the code clean, modular, simple and functional. Quick and dirty solutions must be completely avoided, in favor of robust, well-designed, general solutions. - -In some case, you will be asked to perform a seemingly impossible task, either because the user is unaware, or because you don't grasp how to do it properly. In these cases, DO NOT ATTEMPT TO IMPLEMENT A HALF-BAKED SOLUTION JUST TO SATISFY THE USER'S REQUEST. If the task seems to hard, be honest that you couldn't solve it in the proper way, leave the code unchanged, explain the situation to the user and ask for further feedback and clarifications. - -Similarly, sometimes a task together with other specifications will naturally lead you to a complex or ugly-looking solution. In that case, STOP AND ASK FOR FEEDBACK. It could be entirely possible that in order to complete the task cleanly, some higher-level aspect of the codebase needs to be refactored, a data type changed, or an entire new module created. Discuss this with the user instead of trying to force an ugly solution into the existing codebase. diff --git a/docs/GIT-NATIVE-SHARES.md b/docs/GIT-NATIVE-SHARES.md index 8bf046e..b760c6f 100644 --- a/docs/GIT-NATIVE-SHARES.md +++ b/docs/GIT-NATIVE-SHARES.md @@ -152,13 +152,41 @@ Simpler for agents and humans to create — `echo "notes/foo.md" > .shares///` → tier 1 API -- [ ] Route `/s/` → tier 2 API -- [ ] Delete legacy API which is no longer supported by the UI +- [x] Update share viewer to handle new URL formats +- [x] Route `/s///` → tier 1 API +- [x] Route `/s/` → tier 2 API +- [x] Delete legacy API which is no longer supported by the UI -### Task 7: Skill doc & tooling +### Task 7: Frontend — Git-native share creation -- [ ] Update `skills/vibenote/SKILL.md` with new sharing workflow -- [ ] Helper script or instructions for generating `.shares/.repo-id` and opaque URLs -- [ ] Document how agents can create shares purely via git +Replace the server-side share creation flow with a pure-git one. Instead of `POST /v1/shares`, +the UI writes `.shares/` directly to the repo via the GitHub Contents API and computes +the opaque URL client-side. `POST /v1/repo-id` is idempotent, so the UI always calls it when +creating a share — no need to track whether the repo was previously registered. + +Share creation flow: +1. Read `.shares/.repo-id` from the repo. If absent, generate a random repoId and commit the file. +2. Call `POST /v1/repo-id` with the repoId (always — it's a no-op if already registered). +3. Generate a random shareId, compute the opaque URL segment client-side. +4. Commit `.shares/` containing the note path. +5. Return the opaque URL to the UI immediately — no further server round-trip needed. + +- [x] Implement share creation flow as above +- [x] Update `ShareDialog` to show the computed opaque URL +- [x] Revoking a share: delete `.shares/` from the repo via GitHub Contents API +- [x] Looking up an existing share: scan local store (populated by sync, in-memory, no network call) + +### Task 8: Frontend — Remove legacy sharing system + +Once Task 7 is complete, the old server-side share store is fully superseded. + +- [x] Remove `getShareLinkForNote`, `createShareLink`, `revokeShareLink` from `src/lib/backend.ts` +- [x] Remove old share state management from `src/data.ts` +- [x] Remove old `/v1/shares` and `/v1/share-links/:id` endpoints from `server/src/sharing.ts` +- [x] Remove `server/src/share-store.ts` +- [x] Remove `server/data/shares.json` + +### Task 10: Skill doc & tooling + +- [x] Update `skills/vibenote/SKILL.md` with new sharing workflow +- [x] Document how agents can create shares purely via git (covered in SKILL.md) diff --git a/server/src/__tests__/git-shares.test.ts b/server/src/__tests__/git-shares.test.ts index 8398344..81451fe 100644 --- a/server/src/__tests__/git-shares.test.ts +++ b/server/src/__tests__/git-shares.test.ts @@ -332,7 +332,7 @@ describe('Tier 2 — Opaque shares', () => { expect(json.error).toBe('share not found'); }); - it('10. metadata does not leak internal details', async () => { + it('10. metadata exposes owner but not repo or shareId', async () => { setupRepoMock(ENC_OWNER, ENC_REPO, { [`.shares/${SHARE_ID}`]: 'notes/private.md', 'notes/private.md': MARKDOWN_BODY, @@ -342,8 +342,7 @@ describe('Tier 2 — Opaque shares', () => { expect(res.status).toBe(200); const json = await res.json(); - expect(json).toEqual({ ok: true }); - expect(json).not.toHaveProperty('owner'); + expect(json).toEqual({ owner: ENC_OWNER }); expect(json).not.toHaveProperty('repo'); expect(json).not.toHaveProperty('shareId'); }); diff --git a/server/src/__tests__/share-store.test.ts b/server/src/__tests__/share-store.test.ts deleted file mode 100644 index 744c757..0000000 --- a/server/src/__tests__/share-store.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Tests for the file-backed ShareStore implementation. -import { describe, expect, it, beforeEach } from 'vitest'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { createShareStore } from '../share-store.ts'; - -const SAMPLE = { - id: 'abc123sample', - owner: 'octo', - repo: 'notes', - branch: 'main', - path: 'docs/demo.md', - createdByUserId: 'u1', - createdByLogin: 'octo-dev', - installationId: 42, -} as const; - -describe('ShareStore', () => { - let filePath: string; - - beforeEach(async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'share-store-')); - filePath = path.join(dir, 'shares.json'); - }); - - it('creates, finds, revokes, and persists share records', async () => { - const store = createShareStore({ filePath }); - await store.init(); - - const created = await store.create(SAMPLE); - expect(created.id).toBe(SAMPLE.id); - expect(created.createdAt.length).toBeGreaterThan(0); - - const found = store.findActiveByNote(SAMPLE.owner, SAMPLE.repo, SAMPLE.path); - expect(found?.id).toBe(SAMPLE.id); - - await store.revoke(SAMPLE.id); - expect(store.findActiveByNote(SAMPLE.owner, SAMPLE.repo, SAMPLE.path)).toBeUndefined(); - - const reloaded = createShareStore({ filePath }); - await reloaded.init(); - expect(reloaded.get(SAMPLE.id)).toBeUndefined(); - expect(reloaded.findActiveByNote(SAMPLE.owner, SAMPLE.repo, SAMPLE.path)).toBeUndefined(); - }); -}); diff --git a/server/src/env.ts b/server/src/env.ts index 8ba6192..ff64332 100644 --- a/server/src/env.ts +++ b/server/src/env.ts @@ -19,7 +19,6 @@ type Env = { SESSION_JWT_SECRET: string; SESSION_STORE_FILE: string; SESSION_ENCRYPTION_KEY: string; - SHARE_STORE_FILE: string; REPO_ID_STORE_FILE: string; PUBLIC_VIEWER_BASE_URL: string; }; @@ -52,7 +51,6 @@ function getEnv(): Env { const SESSION_JWT_SECRET = process.env.SESSION_JWT_SECRET ?? 'dev-session-secret-change-me'; const SESSION_STORE_FILE = process.env.SESSION_STORE_FILE ?? './server/data/sessions.json'; const SESSION_ENCRYPTION_KEY = must('SESSION_ENCRYPTION_KEY'); - const SHARE_STORE_FILE = process.env.SHARE_STORE_FILE ?? './server/data/shares.json'; const REPO_ID_STORE_FILE = process.env.REPO_ID_STORE_FILE ?? './server/data/repo-ids.json'; const PUBLIC_VIEWER_BASE_URL = process.env.PUBLIC_VIEWER_BASE_URL ?? 'https://vibenote.dev'; const GITHUB_APP_ID = Number(must('GITHUB_APP_ID')); @@ -74,7 +72,6 @@ function getEnv(): Env { SESSION_JWT_SECRET, SESSION_STORE_FILE, SESSION_ENCRYPTION_KEY, - SHARE_STORE_FILE, REPO_ID_STORE_FILE, PUBLIC_VIEWER_BASE_URL, }; diff --git a/server/src/git-shares.ts b/server/src/git-shares.ts index b42984a..15464f9 100644 --- a/server/src/git-shares.ts +++ b/server/src/git-shares.ts @@ -247,7 +247,8 @@ function gitShareEndpoints(app: express.Express) { handleErrors(async (req, res) => { const { owner, repo, shareId } = getOpaqueShareParams(req); await fetchSharePath(owner, repo, shareId); // validate share exists - res.json({ ok: true }); + // Return owner (repo owner name) but not repo or shareId — URL stays opaque. + res.json({ owner }); }) ); diff --git a/server/src/index.ts b/server/src/index.ts index ed48e90..858d917 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -13,7 +13,6 @@ import { } from './api.ts'; import { createSessionStore } from './session-store.ts'; import { handleErrors, HttpError, requireSession } from './common.ts'; -import { sharingEndpoints } from './sharing.ts'; import { gitShareEndpoints } from './git-shares.ts'; declare module 'express-serve-static-core' { @@ -138,7 +137,6 @@ app.post('/v1/webhooks/github', (_req, res) => { res.status(204).end(); }); -sharingEndpoints(app); gitShareEndpoints(app); const server = app.listen(env.PORT, () => { diff --git a/server/src/share-store.ts b/server/src/share-store.ts deleted file mode 100644 index 2f1f339..0000000 --- a/server/src/share-store.ts +++ /dev/null @@ -1,148 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; - -export type ShareRecord = { - id: string; - owner: string; - repo: string; - branch: string; - path: string; - createdByUserId: string; - createdByLogin: string; - createdAt: string; - installationId: number; -}; - -export type ShareStoreOptions = { - filePath: string; -}; - -type ShareRecordInput = Omit; - -type ShareStoreData = { - records: ShareRecord[]; -}; - -const FILE_MODE = 0o600; - -export function createShareStore(options: ShareStoreOptions): ShareStoreInstance { - return new ShareStore(options); -} - -export type ShareStoreInstance = { - init(): Promise; - create(record: ShareRecordInput): Promise; - get(id: string): ShareRecord | undefined; - findActiveByNote(owner: string, repo: string, path: string): ShareRecord | undefined; - listByRepo(owner: string, repo: string): ShareRecord[]; - revoke(id: string): Promise; -}; - -class ShareStore implements ShareStoreInstance { - #filePath: string; - #dirPath: string; - #shares: Map; - #noteIndex: Map; - #persistQueue: Promise; - - constructor(options: ShareStoreOptions) { - if (!options.filePath || options.filePath.trim().length === 0) { - throw new Error('share store requires file path'); - } - this.#filePath = path.resolve(options.filePath); - this.#dirPath = path.dirname(this.#filePath); - this.#shares = new Map(); - this.#noteIndex = new Map(); - this.#persistQueue = Promise.resolve(); - } - - async init(): Promise { - await fs.mkdir(this.#dirPath, { recursive: true }); - try { - let raw = await fs.readFile(this.#filePath, 'utf8'); - let parsed = JSON.parse(raw) as ShareStoreData | ShareRecord[]; - if (Array.isArray(parsed)) { - this.#hydrate(parsed); - } else if (parsed && typeof parsed === 'object' && Array.isArray(parsed.records)) { - this.#hydrate(parsed.records); - } else { - this.#hydrate([]); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - await this.#persist(); - } else { - throw error; - } - } - } - - async create(record: ShareRecordInput): Promise { - let nowIso = new Date().toISOString(); - let finalRecord: ShareRecord = { - ...record, - createdAt: nowIso, - }; - this.#shares.set(finalRecord.id, finalRecord); - this.#noteIndex.set(noteKey(finalRecord.owner, finalRecord.repo, finalRecord.path), finalRecord.id); - await this.#persist(); - return finalRecord; - } - - get(id: string): ShareRecord | undefined { - return this.#shares.get(id); - } - - findActiveByNote(owner: string, repo: string, relativePath: string): ShareRecord | undefined { - let key = noteKey(owner, repo, relativePath); - let id = this.#noteIndex.get(key); - if (!id) return undefined; - let record = this.#shares.get(id); - return record; - } - - listByRepo(owner: string, repo: string): ShareRecord[] { - let results: ShareRecord[] = []; - for (let record of this.#shares.values()) { - if (record.owner === owner && record.repo === repo) { - results.push(record); - } - } - results.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); - return results; - } - - async revoke(id: string): Promise { - let existing = this.#shares.get(id); - if (!existing) return false; - this.#shares.delete(id); - this.#noteIndex.delete(noteKey(existing.owner, existing.repo, existing.path)); - await this.#persist(); - return true; - } - - #hydrate(records: ShareRecord[]): void { - this.#shares.clear(); - this.#noteIndex.clear(); - for (let record of records) { - if (!record || typeof record.id !== 'string') continue; - this.#shares.set(record.id, record); - this.#noteIndex.set(noteKey(record.owner, record.repo, record.path), record.id); - } - } - - async #persist(): Promise { - let serialized: ShareRecord[] = Array.from(this.#shares.values()); - let payload: ShareStoreData = { records: serialized }; - this.#persistQueue = this.#persistQueue.then(async () => { - let tmpPath = `${this.#filePath}.tmp`; - await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { mode: FILE_MODE }); - await fs.rename(tmpPath, this.#filePath); - }); - await this.#persistQueue; - } -} - -function noteKey(owner: string, repo: string, filePath: string): string { - return `${owner.toLowerCase()}::${repo.toLowerCase()}::${filePath}`; -} diff --git a/server/src/sharing.ts b/server/src/sharing.ts deleted file mode 100644 index eafe512..0000000 --- a/server/src/sharing.ts +++ /dev/null @@ -1,353 +0,0 @@ -import crypto from 'node:crypto'; -import type express from 'express'; -import { type Env, env } from './env.ts'; -import { type SessionClaims } from './api.ts'; -import { type SessionStoreInstance } from './session-store.ts'; -import { createShareStore, type ShareRecord } from './share-store.ts'; -import { getRepoInstallationId, installationRequest } from './github-app.ts'; -import { resolveAssetPath, encodeAssetPath, collectAssetPaths } from './share-assets.ts'; -import { handleErrors, HttpError, requireSession } from './common.ts'; - -export { sharingEndpoints }; - -const shareAssetCache = new Map; cachedAt: number }>(); -const SHARE_ASSET_CACHE_TTL_MS = 5 * 60 * 1000; - -const shareStore = createShareStore({ - filePath: env.SHARE_STORE_FILE, -}); -await shareStore.init(); - -function sharingEndpoints(app: express.Express) { - app.post( - '/v1/shares', - requireSession, - handleErrors(async (req, res) => { - let session = req.sessionUser!; - let { owner, repo, path, branch } = parseShareBody(req.body); - if (!owner || !repo || !path || !branch) throw Error('invalid owner/repo/path/branch'); - - // we are careful that different statuses (not found, no access) are indistinguishable - // so you can't probe for existing private repos - let installationId: number; - try { - installationId = await getRepoInstallationId(env, owner, repo); - } catch { - throw HttpError(404, 'note not found or insufficient permissions to share note'); - } - - // verify user has write access to the repo, otherwise they are not allowed to create a share. - // (they could guess a repo/owner/path and get access to private notes otherwise!) - let permission = await fetchCollaboratorPermission(env, installationId, owner, repo, session.login); - if (permission === null || !hasWritePermission(permission)) - throw HttpError(404, 'note not found or insufficient permissions to share note'); - - let noteExists = await verifyNoteExists({ env, installationId, owner, repo, path, branch }); - if (!noteExists) throw HttpError(404, 'note not found or insufficient permissions to share note'); - - // once access is verified, we either bail if a share already exists, or create a new one - let existing = shareStore.findActiveByNote(owner, repo, path); - if (existing) return res.status(200).json(shareResponse(existing, env)); - - let id = generateShareId(); - let record = await shareStore.create({ - id, - owner, - repo, - branch, - path, - createdByLogin: session.login, - createdByUserId: session.sub, - installationId, - }); - console.log(`[vibenote] share created ${owner}/${repo}`); - res.status(201).json(shareResponse(record, env)); - }) - ); - - app.get( - '/v1/shares', - requireSession, - handleErrors(async (req, res) => { - const session = req.sessionUser!; - let { owner, repo, path } = parseShareBody(req.query); - if (!owner || !repo || !path) throw Error('invalid owner/repo/path'); - - // we are careful that different statuses (not found, no access) are indistinguishable - // so you can't probe for existing private repos - let installationId: number; - try { - installationId = await getRepoInstallationId(env, owner, repo); - } catch { - throw HttpError(404, 'note not found. either no access or invalid owner/repo/path/branch'); - } - - // verify user has write access to the repo, otherwise they are not allowed to obtain sharing links. - // (they could guess a repo/owner/path and get access to private notes otherwise!) - const permission = await fetchCollaboratorPermission(env, installationId, owner, repo, session.login); - if (permission === null || !hasReadPermission(permission)) - throw HttpError(404, 'note not found. either no access or invalid owner/repo/path/branch'); - - // once access is verified, we either return existing share, or null - const existing = shareStore.findActiveByNote(owner, repo, path); - res.json({ share: existing ? shareResponse(existing, env) : null }); - }) - ); - - app.delete( - '/v1/shares/:id', - requireSession, - handleErrors(async (req, res) => { - const record = getShareRecord(req); - const session = req.sessionUser!; - const canRevoke = await canUserRevokeShare(env, session, record); - if (!canRevoke) throw HttpError(403, 'insufficient permissions to revoke share'); - - const removed = await shareStore.revoke(record.id); - if (!removed) throw HttpError(404, 'share not found'); - - console.log(`[vibenote] share revoked ${record.owner}/${record.repo}`); - shareAssetCache.delete(record.id); - res.status(204).end(); - }) - ); - - app.get( - '/v1/share-links/:id', - handleErrors(async (req, res) => { - const record = getShareRecord(req); - res.json({ id: record.id, createdBy: { login: record.createdByLogin } }); - }) - ); - - app.get( - '/v1/share-links/:id/content', - handleErrors(async (req, res) => { - const record = getShareRecord(req); - const ghRes = await fetchShareContent(env, record, record.path); - const text = await ghRes.text(); - cacheShareAssets(record.id, record.path, text); - res - .status(200) - .setHeader('Content-Type', 'text/markdown; charset=utf-8') - .setHeader('Cache-Control', 'no-store') - .send(text); - }) - ); - - app.get( - '/v1/share-links/:id/assets', - handleErrors(async (req, res) => { - const record = getShareRecord(req); - const rawPathParam = decodeURIComponent(asTrimmedString(req.query.path)); - const pathCandidate = resolveAssetPath(record.path, rawPathParam); - if (!pathCandidate) throw Error('invalid asset path'); - let allowedPaths = await ensureShareAssetsLoaded(record, env); - if (!allowedPaths.has(pathCandidate)) throw HttpError(404, 'asset not found'); - const ghRes = await fetchShareContent(env, record, pathCandidate); - const buffer = Buffer.from(await ghRes.arrayBuffer()); - const contentType = ghRes.headers.get('Content-Type') ?? 'application/octet-stream'; - const headers: Record = { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=300', - 'Content-Security-Policy': "default-src 'none'", - Vary: 'Accept-Encoding', - }; - const etag = ghRes.headers.get('ETag'); - if (etag) headers.ETag = etag; - const lastModified = ghRes.headers.get('Last-Modified'); - if (lastModified) headers['Last-Modified'] = lastModified; - res.status(200).set(headers).send(buffer); - }) - ); -} - -function shareResponse(record: ShareRecord, env: Env) { - return { - id: record.id, - owner: record.owner, - repo: record.repo, - path: record.path, - branch: record.branch, - createdAt: record.createdAt, - createdBy: { - login: record.createdByLogin, - userId: record.createdByUserId, - }, - url: buildShareUrl(env.PUBLIC_VIEWER_BASE_URL, record.id), - }; -} - -function buildShareUrl(base: string, id: string): string { - const normalized = base.replace(/\/+$/, ''); - return `${normalized}/s/${id}`; -} - -async function verifyNoteExists(options: { - env: Env; - installationId: number; - owner: string; - repo: string; - path: string; - branch: string; -}): Promise { - const { env, installationId, owner, repo, path, branch } = options; - const res = await installationRequest( - env, - installationId, - `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodeAssetPath( - path - )}?ref=${encodeURIComponent(branch)}`, - { headers: { Accept: 'application/vnd.github+json' } } - ); - if (res.status === 404) return false; - if (!res.ok) { - const text = await res.text(); - throw HttpError(502, `github error ${res.status}: ${text}`); - } - return true; -} - -async function fetchCollaboratorPermission( - env: Env, - installationId: number, - owner: string, - repo: string, - login: string -): Promise { - try { - const res = await installationRequest( - env, - installationId, - `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent( - login - )}/permission`, - { headers: { Accept: 'application/vnd.github+json' } } - ); - if (res.status === 404 || res.status === 403) { - return null; - } - if (!res.ok) { - const text = await res.text(); - throw HttpError(502, `github error ${res.status}: ${text}`); - } - const json = (await res.json()) as { permission?: string } | null; - const permission = json && typeof json.permission === 'string' ? json.permission : undefined; - return permission ?? null; - } catch (error) { - if (error instanceof HttpError) throw error; - const message = error instanceof Error && error.message ? error.message : String(error); - throw HttpError(502, message); - } -} - -async function canUserRevokeShare(env: Env, session: SessionClaims, record: ShareRecord): Promise { - if (session.sub === record.createdByUserId) return true; - try { - const permission = await fetchCollaboratorPermission( - env, - record.installationId, - record.owner, - record.repo, - session.login - ); - return permission !== null && hasWritePermission(permission); - } catch { - return false; - } -} - -function asTrimmedString(input: unknown): string { - if (typeof input !== 'string') return ''; - return input.trim(); -} - -function asRecord(obj: unknown): Record { - return typeof obj !== 'object' || obj === null ? {} : (obj as Record); -} - -function parseShareBody(inputBody: unknown) { - let body = asRecord(inputBody); - let owner = asTrimmedString(body.owner); - let repo = asTrimmedString(body.repo); - let branch = asTrimmedString(body.branch) || 'main'; - - // we only allow sharing .md files, and validate/sanitize the path - let path = asTrimmedString(body.path); - if (!path.toLowerCase().endsWith('.md')) { - path = ''; - } else { - path = path.replace(/\\/g, '/').replace(/^\/+/, ''); - if (path.includes('..')) path = ''; - } - return { owner, repo, path, branch }; -} - -function getShareRecord(req: express.Request) { - let id = getPathParam(req, 'id'); - if (!id || !isValidShareId(id)) throw HttpError(404, 'share not found'); - const record = shareStore.get(id); - if (!record || record.id !== id) throw HttpError(404, 'share not found'); - return record; -} - -function generateShareId(): string { - return crypto.randomBytes(18).toString('base64url'); -} - -function isValidShareId(id: string): boolean { - return /^[A-Za-z0-9_-]{10,}$/.test(id); -} - -function getPathParam(req: express.Request, key: string): string | undefined { - const params = req.params as Record; - const value = params[key]; - return typeof value === 'string' ? value : undefined; -} - -function hasReadPermission(permission: string): boolean { - return ( - permission === 'admin' || - permission === 'maintain' || - permission === 'write' || - permission === 'triage' || - permission === 'read' - ); -} - -function hasWritePermission(permission: string): boolean { - return permission === 'admin' || permission === 'maintain' || permission === 'write'; -} - -async function fetchShareContent(env: Env, record: ShareRecord, path: string) { - const ghRes = await installationRequest( - env, - record.installationId, - `/repos/${encodeURIComponent(record.owner)}/${encodeURIComponent(record.repo)}/contents/${encodeAssetPath( - path - )}?ref=${encodeURIComponent(record.branch)}`, - { headers: { Accept: 'application/vnd.github.raw' } } - ); - if (ghRes.status === 404) throw HttpError(404, 'content not found'); - if (!ghRes.ok) { - const text = await ghRes.text(); - throw HttpError(502, `github error ${ghRes.status}: ${text}`); - } - return ghRes; -} - -function cacheShareAssets(shareId: string, notePath: string, markdown: string): Set { - const paths = collectAssetPaths(notePath, markdown); - shareAssetCache.set(shareId, { paths, cachedAt: Date.now() }); - return paths; -} - -async function ensureShareAssetsLoaded(record: ShareRecord, env: Env): Promise> { - const cached = shareAssetCache.get(record.id); - if (cached && Date.now() - cached.cachedAt <= SHARE_ASSET_CACHE_TTL_MS) { - return cached.paths; - } - const ghRes = await fetchShareContent(env, record, record.path); - const markdown = await ghRes.text(); - return cacheShareAssets(record.id, record.path, markdown); -} diff --git a/skills/vibenote/SKILL.md b/skills/vibenote/SKILL.md index 9b260fe..5751b05 100644 --- a/skills/vibenote/SKILL.md +++ b/skills/vibenote/SKILL.md @@ -8,7 +8,7 @@ metadata: # VibeNote Skill -VibeNote is a git-native notes app that renders Markdown from GitHub repos. Notes can be shared via secret links. +VibeNote is a git-native notes app that renders Markdown from GitHub repos. Notes can be shared via secret links stored directly in the repo. ## Working with Notes (Read/Write) @@ -26,19 +26,14 @@ VibeNote will pick up changes automatically on the next sync. ## Reading Shared Notes -There are three share URL formats. All are client-side rendered — rewrite to the API to fetch content. +There are two share URL formats. All are client-side rendered — rewrite to the API to fetch content directly. -### Legacy shares (old format) -``` -vibenote.dev/s/ → api.vibenote.dev/v1/share-links//content -``` - -### Git-native Tier 1 (open shares — owner/repo/shareId visible in URL) +### Tier 1 — open shares (owner/repo visible in URL) ``` vibenote.dev/s/// → api.vibenote.dev/v1/git-shares////content ``` -### Git-native Tier 2 (opaque shares — repo identity hidden) +### Tier 2 — opaque shares (repo identity hidden) ``` vibenote.dev/s/ → api.vibenote.dev/v1/git-shares//content ``` @@ -49,67 +44,63 @@ All content endpoints return raw `text/markdown`. ### Example ``` -# User sends: https://vibenote.dev/s/-0Fgm7cnqd8yZCfnULdY9oO5 -# Fetch this instead: -web_fetch https://api.vibenote.dev/v1/share-links/-0Fgm7cnqd8yZCfnULdY9oO5/content +# User sends: https://vibenote.dev/s/BR6IvcSC4zqhpdi7QE4gw5L-1n3N3m-k +# Fetch the content directly: +web_fetch https://api.vibenote.dev/v1/git-shares/BR6IvcSC4zqhpdi7QE4gw5L-1n3N3m-k/content ``` ## Creating Git-Native Shares -Shares are created by committing a JSON descriptor to `.shares/` in the repo: +Shares are plain-text files committed to the `.shares/` folder of the repo. No server interaction required — just a git commit. -```json -// .shares/.json -{ "path": "notes/my-note.md" } +```bash +# Content is just the note path (plain text, no JSON) +echo "notes/my-note.md" > .shares/ +git add .shares/ && git commit -m "share: notes/my-note.md" && git push ``` -The shareId becomes part of the share URL. **See the security rules below before choosing a shareId.** - -For opaque (Tier 2) shares, the repo must have a repoId registered with the server. The share URL segment is then `base64url(repoId_bytes[8] || shareId_bytes[16])`, constructable locally without a server round-trip. +The shareId becomes part of the share URL: +- **Tier 1 URL**: `https://vibenote.dev/s///` +- **Tier 2 URL**: requires the repo to have a repoId registered (see below) ### ⚠️ Security: ShareId Entropy is MANDATORY on Private Repos **ShareIds on private repos MUST be cryptographically random.** This is not optional. -Why: The Tier 1 endpoint (`/v1/git-shares////content`) is always available. It uses the server's GitHub App credentials — not yours. If an attacker can guess `owner/repo` and `shareId`, they can: -1. Confirm the private repo exists (GitHub deliberately hides this) -2. Read the note content without any credentials - -Tier 2 opaque URLs hide the shareId inside the URL segment, but the shareId is still a plaintext filename in `.shares/` in the repo. Tier 1 is always open and accessible to anyone who knows all three values. +Why: The Tier 1 endpoint (`/v1/git-shares////content`) is always publicly accessible via the server's GitHub App credentials. If an attacker can guess `owner/repo/shareId`, they can read private note content without credentials. **Always generate shareIds like this:** ```js -crypto.randomBytes(16).toString('base64url') // 22 chars, 128 bits +crypto.randomBytes(16).toString('base64url') // 22 chars, 128 bits of entropy ``` -Short human-readable shareIds (e.g. `weekly-update`) are only safe on **public repos**, where the content is already public. +Short human-readable shareIds (e.g. `weekly-update`) are only safe on **public repos**. -## Share Link API +## Revoking Shares -Base URL: `https://api.vibenote.dev` +Delete the `.shares/` file and push: -All mutating endpoints require a VibeNote session JWT in the `Authorization: Bearer ` header, where `` is the session JWT obtained after authenticating via the VibeNote web UI. Currently there is no programmatic auth — share creation must be done through the VibeNote web UI. +```bash +git rm .shares/ +git commit -m "revoke share" && git push +``` + +## Tier 2 Opaque URLs -### Endpoints +To use Tier 2 (opaque) share URLs, the repo needs a repoId registered with the server. The repoId is stored as a plain-text file in the repo itself: -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| `GET` | `/v1/share-links/:id/content` | None | Raw markdown of a shared note | -| `GET` | `/v1/share-links/:id/assets?path=` | None | Assets (images) referenced in a shared note | -| `GET` | `/v1/share-links/:id` | None | Share metadata (id, creator login) | -| `POST` | `/v1/shares` | Session | Create a share link | -| `GET` | `/v1/shares?owner=&repo=&path=` | Session | Look up existing share for a note | -| `DELETE` | `/v1/shares/:id` | Session | Revoke a share link | +```bash +# .shares/.repo-id contains an 11-char base64url string, e.g. "BR6IvcSC4zo" +cat .shares/.repo-id +``` -### Create Share Body (POST /v1/shares) +Register it with the server (no auth required — file presence in the repo is proof of write access): -```json -{ - "owner": "acme-org", - "repo": "team-notes", - "path": "notes/weekly-update.md", - "branch": "main" -} +```bash +curl -X POST https://api.vibenote.dev/v1/repo-id \ + -H "Content-Type: application/json" \ + -d '{"repoId":"BR6IvcSC4zo","owner":"zksecurity","repo":"vibenote"}' ``` -Response includes the share `url` field with the public link. +The opaque URL segment is then `base64url(repoId_bytes[8] || shareId_bytes[16])`, constructable locally without further server calls. + diff --git a/src/data.ts b/src/data.ts index 62e6996..c00b56e 100644 --- a/src/data.ts +++ b/src/data.ts @@ -27,12 +27,14 @@ import { import { getRepoMetadata as apiGetRepoMetadata, getInstallUrl as apiGetInstallUrl, - getShareLinkForNote as apiGetShareLinkForNote, - createShareLink as apiCreateShareLink, - revokeShareLink as apiRevokeShareLink, type RepoMetadata, - type ShareLink, } from './lib/backend'; +import { + createGitShare, + revokeGitShare, + lookupCachedShare, + type GitShareLink, +} from './lib/git-share-ops'; import { buildRemoteConfig, syncBidirectional, @@ -67,7 +69,7 @@ const AUTO_SYNC_POLL_INTERVAL_MS = 180_000; type ShareState = { status: 'idle' | 'loading' | 'ready' | 'error'; - link?: ShareLink; + link?: GitShareLink; error?: string; }; @@ -286,27 +288,11 @@ function useRepoData({ slug, route, recordRecent, setActivePath }: RepoDataInput let initialPullRef = useRef({ done: false }); let shareRequestRef = useRef<{ owner: string; repo: string; path: string } | null>(null); - const loadShareForTarget = useCallback(async (target: { owner: string; repo: string; path: string }) => { + // Synchronous localStorage lookup — no network call needed. + const loadShareForTarget = useCallback((target: { owner: string; repo: string; path: string }) => { shareRequestRef.current = target; - setShareState((prev) => { - if (prev.status === 'ready' && prev.link && shareMatchesTarget(prev.link, target)) { - return prev; - } - return { status: 'loading' }; - }); - try { - const link = await apiGetShareLinkForNote(target.owner, target.repo, target.path); - if (shareRequestRef.current && !shareTargetEquals(shareRequestRef.current, target)) return; - if (link) { - setShareState({ status: 'ready', link }); - } else { - setShareState({ status: 'ready' }); - } - } catch (error) { - if (shareRequestRef.current && !shareTargetEquals(shareRequestRef.current, target)) return; - logError(error); - setShareState({ status: 'error', error: formatError(error) }); - } + const link = lookupCachedShare(target.owner, target.repo, target.path); + setShareState(link ? { status: 'ready', link } : { status: 'ready' }); }, []); // Kick off the one-time remote import when visiting a writable repo we have not linked yet. @@ -655,21 +641,11 @@ function useRepoData({ slug, route, recordRecent, setActivePath }: RepoDataInput setShareState({ status: 'error', error: 'Select a note to share.' }); return; } - const target = { owner: repoOwner, repo: repoName, path: activePath }; - shareRequestRef.current = target; + shareRequestRef.current = { owner: repoOwner, repo: repoName, path: activePath }; setShareState({ status: 'loading' }); try { - const branch = defaultBranch ?? 'main'; - const share = await apiCreateShareLink({ - owner: target.owner, - repo: target.repo, - path: target.path, - branch, - }); - if (!shareMatchesTarget(share, target)) { - return; - } - setShareState({ status: 'ready', link: share }); + const link = await createGitShare(repoOwner, repoName, activePath); + setShareState({ status: 'ready', link }); } catch (error) { logError(error); setShareState({ status: 'error', error: formatError(error) }); @@ -679,10 +655,10 @@ function useRepoData({ slug, route, recordRecent, setActivePath }: RepoDataInput const revokeShare = async () => { const existing = shareState.link; - if (!existing) return; + if (!existing || !repoOwner || !repoName || !activePath) return; setShareState({ status: 'loading' }); try { - await apiRevokeShareLink(existing.id); + await revokeGitShare(repoOwner, repoName, activePath, existing.shareId); setShareState({ status: 'ready' }); } catch (error) { logError(error); @@ -1068,14 +1044,6 @@ function remapPathForMovedFolder(path: string, fromDir: string, toDir: string): return `${normalizedTo}/${remainder}`; } -function shareMatchesTarget(link: ShareLink, target: { owner: string; repo: string; path: string }): boolean { - return ( - link.owner.toLowerCase() === target.owner.toLowerCase() && - link.repo.toLowerCase() === target.repo.toLowerCase() && - normalizePath(link.path) === normalizePath(target.path) - ); -} - function shareTargetEquals( a: { owner: string; repo: string; path: string }, b: { owner: string; repo: string; path: string } diff --git a/src/data/data.test.ts b/src/data/data.test.ts index 72eed67..e95f699 100644 --- a/src/data/data.test.ts +++ b/src/data/data.test.ts @@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer'; import { act, renderHook, waitFor } from '@testing-library/react'; import { useEffect, useState } from 'react'; import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import type { RepoMetadata, ShareLink } from '../lib/backend'; +import type { RepoMetadata } from '../lib/backend'; import type { RepoRoute } from '../ui/routing'; import { LocalStore, markRepoLinked, recordAutoSyncRun, setLastActiveFileId } from '../storage/local'; import type { RemoteFile } from '../sync/git-sync'; @@ -19,9 +19,6 @@ type AuthMocks = { type BackendMocks = { getRepoMetadata: ReturnType; getInstallUrl: ReturnType; - getShareLinkForNote: ReturnType; - createShareLink: ReturnType; - revokeShareLink: ReturnType; }; type SyncMocks = { @@ -50,9 +47,6 @@ const authModule = vi.hoisted(() => ({ const backendModule = vi.hoisted(() => ({ getRepoMetadata: vi.fn(), getInstallUrl: vi.fn(), - getShareLinkForNote: vi.fn(), - createShareLink: vi.fn(), - revokeShareLink: vi.fn(), })); const syncModule = vi.hoisted(() => ({ @@ -84,9 +78,6 @@ vi.mock('../auth/app-auth', () => ({ vi.mock('../lib/backend', () => ({ getRepoMetadata: backendModule.getRepoMetadata, getInstallUrl: backendModule.getInstallUrl, - getShareLinkForNote: backendModule.getShareLinkForNote, - createShareLink: backendModule.createShareLink, - revokeShareLink: backendModule.revokeShareLink, })); vi.mock('../lib/logging', () => ({ @@ -121,9 +112,6 @@ const mockEnsureFreshAccessToken = authModule.ensureFreshAccessToken; const mockSignOutFromGitHubApp = authModule.signOutFromGitHubApp; const mockGetRepoMetadata = backendModule.getRepoMetadata; -const mockGetShareLinkForNote = backendModule.getShareLinkForNote; -const mockCreateShareLink = backendModule.createShareLink; -const mockRevokeShareLink = backendModule.revokeShareLink; const mockBuildRemoteConfig = syncModule.buildRemoteConfig; const mockListRepoFiles = syncModule.listRepoFiles; @@ -148,18 +136,6 @@ const readOnlyMeta: RepoMetadata = { manageUrl: null, }; -const baseShare: ShareLink = { - id: 'share-test-id', - owner: 'acme', - repo: 'notes', - path: 'alpha.md', - branch: 'main', - createdAt: new Date(0).toISOString(), - createdByLogin: 'tester', - createdByUserId: 'user-1', - url: 'https://share.example/s/share-test-id', -}; - function setRepoMetadata(meta: RepoMetadata) { mockGetRepoMetadata.mockImplementation(async () => ({ ...meta })); } @@ -212,9 +188,6 @@ describe('useRepoData', () => { mockSignOutFromGitHubApp.mockReset(); mockGetRepoMetadata.mockReset(); - mockGetShareLinkForNote.mockReset(); - mockCreateShareLink.mockReset(); - mockRevokeShareLink.mockReset(); mockBuildRemoteConfig.mockReset(); mockListRepoFiles.mockReset(); mockPullRepoFile.mockReset(); @@ -234,10 +207,6 @@ describe('useRepoData', () => { return { owner: owner ?? '', repo: repo ?? '', branch: 'main' }; }); - mockGetShareLinkForNote.mockResolvedValue(null); - mockCreateShareLink.mockResolvedValue({ ...baseShare }); - mockRevokeShareLink.mockResolvedValue(undefined); - setRepoMetadata(writableMeta); }); diff --git a/src/lib/backend.ts b/src/lib/backend.ts index f3dfeb5..b9f56e4 100644 --- a/src/lib/backend.ts +++ b/src/lib/backend.ts @@ -4,12 +4,8 @@ import { fetchPublicRepoInfo } from './github-public'; export { type RepoMetadata, - type ShareLink, getRepoMetadata, getInstallUrl, - getShareLinkForNote, - createShareLink, - revokeShareLink, }; const GITHUB_API_BASE = 'https://api.github.com'; @@ -27,18 +23,6 @@ type RepoMetadata = { manageUrl?: string | null; }; -type ShareLink = { - id: string; - owner: string; - repo: string; - path: string; - branch: string; - createdAt: string; - createdByLogin: string; - createdByUserId: string; - url: string; -}; - async function getRepoMetadata(owner: string, repo: string): Promise { let token = await ensureFreshAccessToken(); @@ -179,84 +163,6 @@ async function getInstallUrl(owner: string, repo: string, returnTo: string): Pro return String(j.url || ''); } -async function getShareLinkForNote(owner: string, repo: string, path: string): Promise { - const sessionToken = getSessionToken(); - if (!sessionToken) return null; - const base = getApiBase(); - const url = new URL(`${base}/v1/shares`); - url.searchParams.set('owner', owner); - url.searchParams.set('repo', repo); - url.searchParams.set('path', path); - const res = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${sessionToken}`, - Accept: 'application/json', - }, - }); - if (res.status === 401) { - return null; - } - if (!res.ok) { - const text = await res.text(); - throw new Error(`share lookup failed (${res.status}): ${text}`); - } - const payload = (await res.json()) as { share?: unknown }; - if (!payload || payload.share === undefined || payload.share === null) { - return null; - } - const parsed = parseShare(payload.share); - if (!parsed) { - throw new Error('invalid share payload'); - } - return parsed; -} - -async function createShareLink(options: { - owner: string; - repo: string; - path: string; - branch: string; -}): Promise { - const sessionToken = getSessionToken(); - if (!sessionToken) throw new Error('missing session token'); - const base = getApiBase(); - const res = await fetch(`${base}/v1/shares`, { - method: 'POST', - headers: { - Authorization: `Bearer ${sessionToken}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify(options), - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`share create failed (${res.status}): ${text}`); - } - const parsed = parseShare(await res.json()); - if (!parsed) { - throw new Error('invalid share payload'); - } - return parsed; -} - -async function revokeShareLink(id: string): Promise { - const sessionToken = getSessionToken(); - if (!sessionToken) throw new Error('missing session token'); - const base = getApiBase(); - const res = await fetch(`${base}/v1/shares/${encodeURIComponent(id)}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${sessionToken}`, - Accept: 'application/json', - }, - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`share revoke failed (${res.status}): ${text}`); - } -} - async function githubGet(token: string, path: string): Promise { return await fetch(`${GITHUB_API_BASE}${path}`, { headers: { @@ -381,51 +287,6 @@ async function installationIncludesRepo( return false; } -function parseShare(input: unknown): ShareLink | null { - if (!input || typeof input !== 'object') return null; - const data = input as Record; - const id = asString(data.id); - const owner = asString(data.owner); - const repo = asString(data.repo); - const path = asString(data.path); - const branch = asString(data.branch); - const createdAt = asString(data.createdAt); - const url = asString(data.url); - const createdBy = - data.createdBy && typeof data.createdBy === 'object' ? (data.createdBy as Record) : null; - const createdByLogin = createdBy ? asString(createdBy.login) : null; - const createdByUserId = createdBy ? asString(createdBy.userId) : null; - if ( - !id || - !owner || - !repo || - !path || - !branch || - !createdAt || - !url || - !createdByLogin || - !createdByUserId - ) { - return null; - } - return { - id, - owner, - repo, - path, - branch, - createdAt, - createdByLogin, - createdByUserId, - url, - }; -} - -function asString(value: unknown): string | null { - if (typeof value === 'string' && value.length > 0) return value; - return null; -} - function buildInstallationManageUrl( owner: string, summary: InstallationSummary | undefined diff --git a/src/lib/git-share-ops.ts b/src/lib/git-share-ops.ts new file mode 100644 index 0000000..c6bcd95 --- /dev/null +++ b/src/lib/git-share-ops.ts @@ -0,0 +1,281 @@ +// Git-native share operations — create, revoke, and look up shares stored as +// plain-text files in .shares/ within the repo. +// +// .shares/.repo-id → random 11-char base64url string (one per repo) +// .shares/ → note path, e.g. "notes/foo.md" +// +// Opaque share URLs: /s/ +// segment = base64url(repoIdBytes[8] ∥ shareIdBytes[16]) = 32 chars +// +// Share files participate in the regular git sync (FileKind 'text'), so the +// local store is always up to date after a sync. lookupCachedShare is a pure +// in-memory scan — no network call needed. + +import { ensureFreshAccessToken, getApiBase } from '../auth/app-auth'; +import { normalizePath } from './util'; +import { getRepoStore, markSynced, hashText } from '../storage/local'; + +export type { GitShareLink }; +export { createGitShare, revokeGitShare, lookupCachedShare }; + +type GitShareLink = { + shareId: string; + url: string; +}; + +// --- Crypto helpers (browser-native, no Node.js) --- + +function toBase64url(bytes: Uint8Array): string { + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function fromBase64url(s: string): Uint8Array { + const base64 = s.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)!; + return bytes; +} + +function generateRepoId(): string { + // 8 random bytes → 11-char base64url + return toBase64url(crypto.getRandomValues(new Uint8Array(8))); +} + +function generateShareId(): string { + // 16 random bytes → 22-char base64url (128 bits of entropy) + return toBase64url(crypto.getRandomValues(new Uint8Array(16))); +} + +// Combine repoId (8 bytes) and shareId (16 bytes) into a 32-char opaque segment. +function computeOpaqueSegment(repoId: string, shareId: string): string { + const combined = new Uint8Array(24); + combined.set(fromBase64url(repoId), 0); + combined.set(fromBase64url(shareId), 8); + return toBase64url(combined); +} + +// --- Local store share lookup --- +// Scans .shares/ files in the local store (populated and kept fresh by the +// regular git sync) to find an existing share for a given note path. +// Entirely in-memory — no network call. + +function lookupCachedShare(owner: string, repo: string, notePath: string): GitShareLink | null { + const store = getRepoStore(`${owner}/${repo}`); + const files = store.listFiles(); + const normalized = normalizePath(notePath); + + // Find .shares/.repo-id first — needed to compute the opaque URL. + const repoIdMeta = files.find((f) => f.path === '.shares/.repo-id'); + if (!repoIdMeta) return null; + const repoIdFile = store.loadFileById(repoIdMeta.id); + if (!repoIdFile) return null; + const repoId = repoIdFile.content.trim(); + + // Find the share file whose content matches the note path. + for (const meta of files) { + if (!meta.path.startsWith('.shares/') || meta.path === '.shares/.repo-id') continue; + const file = store.loadFileById(meta.id); + if (!file) continue; + if (normalizePath(file.content.trim()) !== normalized) continue; + const shareId = meta.path.slice('.shares/'.length); + const url = `${window.location.origin}/s/${computeOpaqueSegment(repoId, shareId)}`; + return { shareId, url }; + } + return null; +} + +// --- GitHub Contents API helpers --- + +const GITHUB_API = 'https://api.github.com'; + +type RepoFileResult = { text: string; sha: string }; + +// Encode UTF-8 text to standard base64 for GitHub Contents API payloads. +function textToBase64(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary); +} + +// Decode GitHub's base64 content (which may contain embedded newlines) to text. +function base64ToText(b64: string): string { + const clean = b64.replace(/\s/g, ''); + const binary = atob(clean); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)!; + return new TextDecoder().decode(bytes); +} + +function encodeRepoPath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/'); +} + +function repoContentsUrl(owner: string, repo: string, path: string): string { + return `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodeRepoPath(path)}`; +} + +async function readRepoFile( + token: string, + owner: string, + repo: string, + path: string +): Promise { + const res = await fetch(repoContentsUrl(owner, repo, path), { + headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}` }, + }); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`GitHub read failed (${res.status}): ${path}`); + const json = await res.json() as Record; + const sha = typeof json.sha === 'string' ? json.sha : ''; + const content = typeof json.content === 'string' ? json.content : ''; + return { text: base64ToText(content), sha }; +} + +// Returns the blob SHA of the newly created file. +async function putRepoFile( + token: string, + owner: string, + repo: string, + path: string, + text: string, + message: string +): Promise { + const res = await fetch(repoContentsUrl(owner, repo, path), { + method: 'PUT', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ message, content: textToBase64(text) }), + }); + if (!res.ok) throw new Error(`GitHub write failed (${res.status}): ${path}`); + const json = await res.json() as Record; + // GitHub returns { content: { sha } } — the blob SHA of the created file + const contentObj = json.content; + const sha = + contentObj && typeof contentObj === 'object' && typeof (contentObj as Record).sha === 'string' + ? (contentObj as Record).sha as string + : ''; + return sha; +} + +async function deleteRepoFile( + token: string, + owner: string, + repo: string, + path: string, + sha: string, + message: string +): Promise { + const res = await fetch(repoContentsUrl(owner, repo, path), { + method: 'DELETE', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ message, sha }), + }); + if (!res.ok) throw new Error(`GitHub delete failed (${res.status}): ${path}`); +} + +// --- Backend repo-id registration --- + +async function registerRepoId(repoId: string, owner: string, repo: string): Promise { + const base = getApiBase(); + const res = await fetch(`${base}/v1/repo-id`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repoId, owner, repo }), + }); + if (!res.ok) throw new Error(`repo-id registration failed (${res.status})`); +} + +// --- Ensure repoId --- +// Read .shares/.repo-id from the local store (preferred, populated by sync) or +// directly from the GitHub API. If absent, generate and commit a fresh one. + +async function ensureRepoId(token: string, owner: string, repo: string): Promise { + const slug = `${owner}/${repo}`; + const store = getRepoStore(slug); + + // Fast path: already in local store from a previous sync or creation + const localMeta = store.findMetaByPath('.shares/.repo-id'); + if (localMeta) { + const local = store.loadFileById(localMeta.id); + if (local?.content) return local.content.trim(); + } + + // Slow path: try to read from GitHub (another device may have committed it) + const existing = await readRepoFile(token, owner, repo, '.shares/.repo-id'); + if (existing) { + const repoId = existing.text.trim(); + // Add to local store so future lookups are instant + const id = store.createFile('.shares/.repo-id', repoId, { kind: 'text' }); + markSynced(slug, id, { remoteSha: existing.sha, syncedHash: hashText(repoId) }); + return repoId; + } + + // First share ever in this repo — generate, commit, and store locally + const repoId = generateRepoId(); + const sha = await putRepoFile(token, owner, repo, '.shares/.repo-id', repoId, 'shares: initialize repo id'); + const id = store.createFile('.shares/.repo-id', repoId, { kind: 'text' }); + markSynced(slug, id, { remoteSha: sha, syncedHash: hashText(repoId) }); + return repoId; +} + +// --- High-level share operations --- + +async function createGitShare(owner: string, repo: string, notePath: string): Promise { + const token = await ensureFreshAccessToken(); + if (!token) throw new Error('Not authenticated — sign in to share notes.'); + const slug = `${owner}/${repo}`; + + // Ensure .shares/.repo-id exists in the repo and is registered with the backend + const repoId = await ensureRepoId(token, owner, repo); + await registerRepoId(repoId, owner, repo); + + // Commit a new share file and mirror it to the local store immediately + const shareId = generateShareId(); + const sha = await putRepoFile(token, owner, repo, `.shares/${shareId}`, notePath, `share: ${notePath}`); + const store = getRepoStore(slug); + const id = store.createFile(`.shares/${shareId}`, notePath, { kind: 'text' }); + markSynced(slug, id, { remoteSha: sha, syncedHash: hashText(notePath) }); + + const url = `${window.location.origin}/s/${computeOpaqueSegment(repoId, shareId)}`; + return { shareId, url }; +} + +async function revokeGitShare( + owner: string, + repo: string, + notePath: string, + shareId: string +): Promise { + const token = await ensureFreshAccessToken(); + if (!token) throw new Error('Not authenticated.'); + + // Read the share file to get its SHA — required by the GitHub delete API + const file = await readRepoFile(token, owner, repo, `.shares/${shareId}`); + if (file) { + await deleteRepoFile( + token, + owner, + repo, + `.shares/${shareId}`, + file.sha, + `share: revoke ${notePath}` + ); + } + + // Remove from local store — sync tombstone will be a no-op since the file + // is already gone from GitHub (the sync handles missing remote gracefully). + const store = getRepoStore(`${owner}/${repo}`); + store.deleteFile(`.shares/${shareId}`); +} diff --git a/src/share/ShareApp.tsx b/src/share/ShareApp.tsx index 1948fb8..338f60f 100644 --- a/src/share/ShareApp.tsx +++ b/src/share/ShareApp.tsx @@ -1,18 +1,18 @@ -// Public share viewer entry that resolves the share id and renders the note. +// Public share viewer entry that resolves the share ref and renders the note. import React, { useEffect, useMemo, useState } from 'react'; import { renderMarkdown } from '../lib/render-markdown'; -import { fetchShareContent, fetchShareMeta, getApiBase, type ShareMetaResponse } from './api'; +import { fetchShareContent, fetchShareMeta, buildAssetUrl, parseShareUrl, type ShareRef, type ShareMetaResponse } from './api'; type ViewerState = - | { status: 'loading'; id: string } - | { status: 'ready'; id: string; meta: ShareMetaResponse; markdown: string } + | { status: 'loading'; ref: ShareRef } + | { status: 'ready'; ref: ShareRef; meta: ShareMetaResponse; markdown: string } | { status: 'error'; message: string } | { status: 'not-found' }; export function ShareApp() { - const shareId = useMemo(resolveShareId, []); + const shareRef = useMemo(parseShareUrl, []); const [state, setState] = useState(() => - shareId ? { status: 'loading', id: shareId } : { status: 'error', message: 'Missing share id.' } + shareRef ? { status: 'loading', ref: shareRef } : { status: 'error', message: 'Missing share id.' } ); useEffect(() => { @@ -32,13 +32,13 @@ export function ShareApp() { }, []); useEffect(() => { - if (!shareId) return; + if (!shareRef) return; let cancelled = false; (async () => { try { - setState({ status: 'loading', id: shareId }); - const contentPromise = fetchShareContent(shareId); - const metaRes = await fetchShareMeta(shareId); + setState({ status: 'loading', ref: shareRef }); + const contentPromise = fetchShareContent(shareRef); + const metaRes = await fetchShareMeta(shareRef); if (cancelled) return; if (metaRes.status === 404) { setState({ status: 'not-found' }); @@ -59,7 +59,7 @@ export function ShareApp() { } const markdown = await contentRes.text(); if (cancelled) return; - setState({ status: 'ready', id: shareId, meta, markdown }); + setState({ status: 'ready', ref: shareRef, meta, markdown }); } catch (error) { if (cancelled) return; setState({ status: 'error', message: formatError(error) }); @@ -68,11 +68,11 @@ export function ShareApp() { return () => { cancelled = true; }; - }, [shareId]); + }, [shareRef]); const renderedHtml = useMemo(() => { if (state.status !== 'ready') return ''; - return renderShareMarkdown(state.markdown, { shareId: state.id }); + return renderShareMarkdown(state.markdown, { shareRef: state.ref }); }, [state]); if (state.status === 'loading') { @@ -115,7 +115,7 @@ export function ShareApp() {

- Shared by @{meta.createdBy.login} on{' '} + Shared by @{meta.owner} on{' '} VibeNote @@ -128,18 +128,7 @@ export function ShareApp() { ); } -function resolveShareId(): string | null { - try { - let pathname = window.location.pathname; - let match = pathname.match(/\/s\/([A-Za-z0-9_-]{6,})/); - if (match && match[1]) return match[1]; - } catch { - // ignore and fall through - } - return null; -} - -function renderShareMarkdown(markdown: string, options: { shareId: string }): string { +function renderShareMarkdown(markdown: string, options: { shareRef: ShareRef }): string { const sanitized = renderMarkdown(markdown, 'share'); try { return rewriteAssetUrls(sanitized, options); @@ -149,7 +138,7 @@ function renderShareMarkdown(markdown: string, options: { shareId: string }): st } } -function rewriteAssetUrls(html: string, options: { shareId: string }): string { +function rewriteAssetUrls(html: string, options: { shareRef: ShareRef }): string { if (typeof window === 'undefined') return html; const container = document.createElement('div'); container.innerHTML = html; @@ -169,7 +158,7 @@ function rewriteAssetUrls(html: string, options: { shareId: string }): string { } continue; } - node.setAttribute(attr, buildAssetUrl(options.shareId, trimmed)); + node.setAttribute(attr, buildAssetUrl(options.shareRef, trimmed)); } } return container.innerHTML; @@ -187,11 +176,6 @@ function isRelativeAssetUrl(value: string): boolean { return true; } -function buildAssetUrl(shareId: string, relativePath: string): string { - const params = new URLSearchParams({ path: relativePath }); - return `${getApiBase()}/v1/share-links/${encodeURIComponent(shareId)}/assets?${params.toString()}`; -} - function formatError(error: unknown): string { if (error instanceof Error && typeof error.message === 'string') return error.message; return String(error); diff --git a/src/share/api.ts b/src/share/api.ts index a8a2c47..b7012f3 100644 --- a/src/share/api.ts +++ b/src/share/api.ts @@ -1,13 +1,22 @@ -// Helpers for the public share viewer to talk to the backend API. -export type { ShareMetaResponse }; -export { getApiBase, fetchShareMeta, fetchShareContent }; +// Helpers for the public share viewer to talk to the backend git-shares API. +export type { ShareRef, ShareMetaResponse }; +export { getApiBase, parseShareUrl, fetchShareMeta, fetchShareContent, buildAssetUrl }; -let cachedBase: string | null = null; +// Discriminated union identifying which tier a share URL belongs to. +type ShareRef = + | { tier: 1; owner: string; repo: string; shareId: string } + | { tier: 2; segment: string }; +// Both tiers return the repo owner; Tier 2 keeps repo and shareId opaque. type ShareMetaResponse = { - createdBy: { login: string }; + owner: string; }; +// Tier 2 opaque segments are exactly 32 base64url characters. +const OPAQUE_SEGMENT_RE = /^[A-Za-z0-9_-]{32}$/; + +let cachedBase: string | null = null; + function getApiBase(): string { if (cachedBase) return cachedBase; const env = (import.meta as any).env || {}; @@ -19,15 +28,54 @@ function getApiBase(): string { return cachedBase; } -async function fetchShareMeta(id: string): Promise { - const res = await fetch(`${getApiBase()}/v1/share-links/${encodeURIComponent(id)}`, { - headers: { Accept: 'application/json' }, - }); - return res; +// Parse the current window location into a ShareRef, or null if the URL is unrecognised. +function parseShareUrl(): ShareRef | null { + try { + const pathname = window.location.pathname; + // Expect /s/ + const match = pathname.match(/^\/s\/(.+)$/); + if (!match || !match[1]) return null; + const rest = match[1]; + const segments = rest.split('/').filter((s) => s !== ''); + + if (segments.length === 3) { + // Tier 1: /s/// + const [owner, repo, shareId] = segments; + if (!owner || !repo || !shareId) return null; + return { tier: 1, owner, repo, shareId }; + } + + if (segments.length === 1 && OPAQUE_SEGMENT_RE.test(segments[0] ?? '')) { + // Tier 2: /s/<32-char opaque segment> + return { tier: 2, segment: segments[0]! }; + } + } catch { + // ignore + } + return null; +} + +function metaUrl(ref: ShareRef): string { + const base = getApiBase(); + if (ref.tier === 1) { + return `${base}/v1/git-shares/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}/${encodeURIComponent(ref.shareId)}`; + } + return `${base}/v1/git-shares/${ref.segment}`; +} + +function contentUrl(ref: ShareRef): string { + return `${metaUrl(ref)}/content`; +} + +function buildAssetUrl(ref: ShareRef, relativePath: string): string { + const params = new URLSearchParams({ path: relativePath }); + return `${metaUrl(ref)}/assets?${params.toString()}`; +} + +async function fetchShareMeta(ref: ShareRef): Promise { + return fetch(metaUrl(ref), { headers: { Accept: 'application/json' } }); } -async function fetchShareContent(id: string): Promise { - return await fetch(`${getApiBase()}/v1/share-links/${encodeURIComponent(id)}/content`, { - headers: { Accept: 'text/markdown' }, - }); +async function fetchShareContent(ref: ShareRef): Promise { + return fetch(contentUrl(ref), { headers: { Accept: 'text/markdown' } }); } diff --git a/src/storage/local.ts b/src/storage/local.ts index 0a06702..9142c23 100644 --- a/src/storage/local.ts +++ b/src/storage/local.ts @@ -1,9 +1,18 @@ -// Local storage persistence for repo files (markdown notes plus binary assets). +// Local storage persistence for repo files (markdown notes, binary assets, and plain-text share descriptors). import { normalizePath } from '../lib/util'; import { logError } from '../lib/logging'; import type { RemoteFile } from '../sync/git-sync'; -export type { FileKind, FileMeta, RepoFile, MarkdownFile, BinaryFile, AssetUrlFile, RepoStoreSnapshot }; +export type { + FileKind, + FileMeta, + RepoFile, + MarkdownFile, + BinaryFile, + AssetUrlFile, + TextFile, + RepoStoreSnapshot, +}; export { basename, @@ -12,11 +21,13 @@ export { isMarkdownFile, isBinaryFile, isAssetUrlFile, + isTextFile, + kindFromPath, debugLog, setDebugEnabled, }; -type FileKind = 'markdown' | 'binary' | 'asset-url'; +type FileKind = 'markdown' | 'binary' | 'asset-url' | 'text'; /** * Backwards-compatible serialized format for file metadata @@ -53,6 +64,7 @@ type RepoFile = FileMeta & { type MarkdownFile = RepoFile & { kind: 'markdown' }; type BinaryFile = RepoFile & { kind: 'binary' }; type AssetUrlFile = RepoFile & { kind: 'asset-url' }; +type TextFile = RepoFile & { kind: 'text' }; function isMarkdownFile(doc: RepoFile): doc is MarkdownFile { return doc.kind === 'markdown'; @@ -66,6 +78,10 @@ function isAssetUrlFile(doc: RepoFile): doc is AssetUrlFile { return doc.kind === 'asset-url'; } +function isTextFile(doc: RepoFile): doc is TextFile { + return doc.kind === 'text'; +} + function serializeIndex(index: FileMeta[]): string { // important: written metadata stays compatible with `StoredFileMeta` return JSON.stringify(index satisfies StoredFileMeta[]); @@ -318,7 +334,7 @@ export class LocalStore { createFile(path: string, content: string, params: { kind?: FileKind } = {}): string { let id = crypto.randomUUID(); path = normalizePath(path); - let kind = params.kind ?? inferKindFromPath(path); + let kind = params.kind ?? kindFromPath(path) ?? 'binary'; let updatedAt = Date.now(); let dir = extractDir(path); let safeName = ensureValidFileName(basename(path)); @@ -939,8 +955,96 @@ function extractExtensionWithDot(baseName: string): string { return baseName.slice(idx); } -function inferKindFromPath(path: string): FileKind { - return /\.md$/i.test(path) ? 'markdown' : 'binary'; +// True binary formats — everything else with an extension is treated as text. +const BINARY_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif']); + +// Common plain-text file extensions found in GitHub repos. +const TEXT_EXTENSIONS = new Set([ + // code + 'ts', + 'tsx', + 'js', + 'jsx', + 'mjs', + 'cjs', + 'py', + 'rb', + 'rs', + 'go', + 'java', + 'kt', + 'scala', + 'c', + 'cpp', + 'cc', + 'h', + 'hpp', + 'cs', + 'swift', + 'php', + 'lua', + 'ex', + 'exs', + 'clj', + 'cljs', + 'hs', + 'ml', + 'mli', + 'r', + // config / data + 'json', + 'jsonc', + 'yaml', + 'yml', + 'toml', + 'ini', + 'cfg', + 'conf', + 'env', + 'xml', + 'csv', + 'sql', + 'graphql', + 'proto', + // web + 'html', + 'htm', + 'css', + 'scss', + 'sass', + 'less', + // shell + 'sh', + 'bash', + 'zsh', + 'fish', + // docs + 'txt', + 'rst', + 'tex', + 'adoc', + // misc + 'diff', + 'patch', +]); + +// Map a repo file path to its FileKind, or null if the file should be ignored. +// - .md → 'markdown' +// - known binary → 'binary' +// - known text → 'text' +// - no extension → 'text' (Makefile, Dockerfile, LICENSE, .gitignore, .shares/… ) +// - unknown ext → null (skip) +function kindFromPath(path: string): FileKind | null { + const lastSlash = path.lastIndexOf('/'); + const filename = path.slice(lastSlash + 1); + const dotIdx = filename.lastIndexOf('.'); + // No dot at all, or dot is only at position 0 (hidden file like .gitignore) → no extension + const ext = dotIdx > 0 ? filename.slice(dotIdx + 1).toLowerCase() : ''; + if (ext === '') return 'text'; + if (ext === 'md') return 'markdown'; + if (BINARY_EXTENSIONS.has(ext)) return 'binary'; + if (TEXT_EXTENSIONS.has(ext)) return 'text'; + return null; } function normalizeMeta(raw: unknown): FileMeta | null { @@ -951,7 +1055,9 @@ function normalizeMeta(raw: unknown): FileMeta | null { let path = stored.path.replace(/^\/+/g, '').replace(/\/+$/g, '') || stored.path; let updatedAt = typeof stored.updatedAt === 'number' && Number.isFinite(stored.updatedAt) ? stored.updatedAt : Date.now(); - let inferredKind = typeof stored.kind === 'string' ? stored.kind : 'markdown'; + // Fall back to path-based inference for legacy data without a stored kind. + // Unknown extensions default to 'binary' — the safest assumption. + let inferredKind = typeof stored.kind === 'string' ? stored.kind : (kindFromPath(path) ?? 'binary'); return { id: stored.id, path, updatedAt, kind: inferredKind }; } diff --git a/src/styles.css b/src/styles.css index 63eee1e..e0e27c0 100644 --- a/src/styles.css +++ b/src/styles.css @@ -871,6 +871,11 @@ textarea:focus-visible { font: var(--code-font); } +/* Full-page text editor — fills the visible workspace area */ +.workspace-panels textarea.text-editor { + min-height: calc(100vh - var(--topbar-height) - 64px); +} + .workspace-panels .preview { overflow: auto; line-height: 1.68; diff --git a/src/sync/git-sync.test.ts b/src/sync/git-sync.test.ts index d74b4ec..d57388f 100644 --- a/src/sync/git-sync.test.ts +++ b/src/sync/git-sync.test.ts @@ -182,12 +182,15 @@ describe.each(remoteScenarios)('syncBidirectional: $label', ({ configure }) => { }); test('syncs tracked image files while ignoring unrelated blobs', async () => { - remote.setFile('data.json', '{"keep":true}'); + // .xyz is an unknown extension — should be ignored by the sync + remote.setFile('data.xyz', 'ignored'); remote.setFile('image.png', 'asset'); store.createFile('OnlyNote.md', '# hello'); await syncBidirectional(store, 'user/repo'); const snapshot = remote.snapshot(); - expect(snapshot.get('data.json')).toBe('{"keep":true}'); + // Unknown extension stays on remote but is not pulled to local store + expect(snapshot.get('data.xyz')).toBe('ignored'); + expect(store.listFiles().find((f) => f.path === 'data.xyz')).toBeUndefined(); expect(snapshot.get('image.png')).toBe('asset'); expect(snapshot.get('OnlyNote.md')).toBe('# hello'); const files = store.listFiles(); diff --git a/src/sync/git-sync.ts b/src/sync/git-sync.ts index aff3009..2c3292a 100644 --- a/src/sync/git-sync.ts +++ b/src/sync/git-sync.ts @@ -21,6 +21,7 @@ import { moveFilePath, debugLog, computeSyncedHash, + kindFromPath, } from '../storage/local'; import { mergeMarkdown } from '../merge/merge'; import { readCachedBlob, writeCachedBlob } from '../storage/blob-cache'; @@ -34,7 +35,6 @@ type RemoteConfig = { owner: string; repo: string; branch: string }; type CommitResponse = { commitSha: string; blobShas: Record }; const GITHUB_API_BASE = 'https://api.github.com'; -const BINARY_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif']); type RepoFileEntry = { path: string; sha: string; kind: FileKind }; @@ -67,7 +67,7 @@ export async function repoExists(owner: string, repo: string): Promise } export async function pullRepoFile(config: RemoteConfig, path: string): Promise { - const kind = fileKindFromPath(path); + const kind = kindFromPath(path); if (!kind) return null; let branch = config.branch || 'main'; let token = await ensureFreshAccessToken(); @@ -165,10 +165,12 @@ function serializeContent(file: PutFilePayload) { if (file.kind === 'asset-url') { throw new Error('asset-url files must be converted to binary content before upload'); } + const isBinary = file.kind === 'binary'; return { path: file.path, - contentBase64: file.kind === 'binary' ? normalizeBase64(file.content) : toBase64(file.content), - encoding: file.kind === 'binary' ? ('base64' as const) : ('utf-8' as const), + // markdown and text files use UTF-8 encoding; binary uses base64 + contentBase64: isBinary ? normalizeBase64(file.content) : toBase64(file.content), + encoding: isBinary ? ('base64' as const) : ('utf-8' as const), }; } @@ -177,8 +179,8 @@ async function buildUploadPayload( doc: RepoFile, baseSha?: string ): Promise { - if (doc.kind === 'markdown') { - return { path: doc.path, content: doc.content, kind: 'markdown', baseSha }; + if (doc.kind === 'markdown' || doc.kind === 'text') { + return { path: doc.path, content: doc.content, kind: doc.kind, baseSha }; } if (doc.kind === 'asset-url') { const pointer = parseBlobPlaceholder(doc.content); @@ -241,8 +243,8 @@ export async function listRepoFiles(config: RemoteConfig): Promise { let { config, path, kind, sha, contentBase64, downloadUrl } = input; - if (kind === 'markdown') { + if (kind === 'markdown' || kind === 'text') { let payload = contentBase64; if (payload === '') { const blob = await fetchBlob(config, sha); if (blob !== null) payload = blob; } - return { path, sha, kind: 'markdown', content: fromBase64(payload) }; + return { path, sha, kind, content: fromBase64(payload) }; } if (kind === 'binary') { if (downloadUrl && isReusableDownloadUrl(downloadUrl)) { @@ -646,6 +648,9 @@ async function materializeRemoteFile(input: { content: buildBlobPlaceholder(config, sha), }; } + if (kind === 'asset-url') { + throw Error('asset-url is a local-only kind, not a remote kind'); + } kind satisfies never; throw Error('unexpected type'); } @@ -700,17 +705,7 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string { return btoa(binary); } -function fileKindFromPath(path: string): 'markdown' | 'binary' | null { - const idx = path.lastIndexOf('.'); - if (idx < 0 || idx === path.length - 1) return null; - const ext = path - .slice(idx + 1) - .toLowerCase() - .trim(); - if (ext === 'md') return 'markdown'; - if (BINARY_EXTENSIONS.has(ext)) return 'binary'; - return null; -} + function hashText(text: string): string { let h = 5381; @@ -970,9 +965,8 @@ export async function syncBidirectional(store: LocalStore, slug: string): Promis merged++; pushed++; debugLog(slug, 'sync:merge', { path: doc.path }); - } else if (doc.kind === 'binary' || doc.kind === 'asset-url') { - // TODO how to resolve conflicts for binary files? - // currently we just use the remote version (seems fairer to pick the version that made it to github first) + } else if (doc.kind === 'binary' || doc.kind === 'asset-url' || doc.kind === 'text') { + // Remote wins — binary files have no merge strategy; text (share) files are never edited locally. updateFile(storeSlug, id, rf.content, rf.kind); markSynced(storeSlug, id, { remoteSha: rf.sha, syncedHash: remoteHash }); remoteMap.set(e.path, rf.sha); diff --git a/src/ui/RepoView.tsx b/src/ui/RepoView.tsx index c003a23..a01574f 100644 --- a/src/ui/RepoView.tsx +++ b/src/ui/RepoView.tsx @@ -2,6 +2,7 @@ import { useMemo, useState, useEffect, useRef } from 'react'; import { FileTree, type FileEntry } from './FileTree'; import { Editor } from './Editor'; +import { TextEditor } from './TextEditor'; import { AssetViewer } from './AssetViewer'; import { RepoSwitcher } from './RepoSwitcher'; import { Toggle } from './Toggle'; @@ -14,6 +15,7 @@ import { isMarkdownFile, isBinaryFile, isAssetUrlFile, + isTextFile, basename, extractDir, stripExtension, @@ -377,6 +379,15 @@ function RepoViewInner({ slug, route, navigate, recordRecent }: RepoViewProps) { /> ) : isBinaryFile(activeFile) || isAssetUrlFile(activeFile) ? ( + ) : isTextFile(activeFile) ? ( + { + actions.saveFile(path, text); + }} + /> ) : null} ) : needsUserAction ? ( diff --git a/src/ui/TextEditor.tsx b/src/ui/TextEditor.tsx new file mode 100644 index 0000000..fb0740d --- /dev/null +++ b/src/ui/TextEditor.tsx @@ -0,0 +1,45 @@ +// Plain-text editor — used for non-markdown text files (.ts, .json, Makefile, …). +// No preview pane, no asset handling; just a textarea. + +import { useEffect, useState } from 'react'; +import type { TextFile } from '../storage/local'; + +export { TextEditor }; + +type Props = { + doc: TextFile; + onChange: (path: string, text: string) => void; + readOnly?: boolean; +}; + +function TextEditor({ doc, onChange, readOnly = false }: Props) { + let [text, setText] = useState(doc.content); + + // Reset when switching to a different file + useEffect(() => { + setText(doc.content); + }, [doc.id]); + + // Reflect external updates (e.g. after sync/merge) + useEffect(() => { + if (text !== doc.content) setText(doc.content); + }, [doc.content]); + + const onInput = (val: string) => { + if (readOnly) return; + setText(val); + onChange(doc.path, val); + }; + + if (readOnly) { + return

{text}
; + } + return ( +