Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions apps/extensions/public/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,81 @@ async function initBrowse() {
load('');
}

/* Owner-only listing editor. Publishing a new build refreshes the bundle but
not the words around it, so without this a listing keeps describing whatever
the extension did the day it was created. */
function editForm(ext) {
return `
<form class="panel hidden" id="editForm" style="margin-top:18px">
<h3 style="margin-top:0">Edit listing</h3>
<p class="hint">Only you can see this. Bundle and version come from your next publish — this is the copy around it.</p>
<div class="field"><label>Name *</label><input type="text" name="name" required value="${esc(ext.name)}" /></div>
<div class="field"><label>Summary <span class="hint">(one line, shown on the browse grid)</span></label><input type="text" name="summary" value="${esc(ext.summary || '')}" /></div>
<div class="field"><label>Description</label><textarea name="description" style="min-height:160px">${esc(ext.description || '')}</textarea></div>
<div class="field"><label>Source / homepage</label><input type="url" name="homepageUrl" value="${esc(ext.homepageUrl || '')}" /></div>
<div class="row">
<button class="btn" type="submit">Save changes</button>
<button class="btn ghost" type="button" id="editCancel">Cancel</button>
</div>
<p id="editOut" style="margin:12px 0 0"></p>
</form>`;
}

function wireEditForm(ext, rerender) {
const btn = document.getElementById('editBtn');
const form = document.getElementById('editForm');
if (!btn || !form) return;

btn.addEventListener('click', () => {
form.classList.toggle('hidden');
if (!form.classList.contains('hidden')) form.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
document.getElementById('editCancel').addEventListener('click', () => form.classList.add('hidden'));

form.addEventListener('submit', async (e) => {
e.preventDefault();
const out = document.getElementById('editOut');
const save = form.querySelector('button[type="submit"]');
const fd = new FormData(form);
// Blank optional fields clear the column; a blank name is rejected server-side.
const body = {
name: String(fd.get('name') || '').trim(),
summary: String(fd.get('summary') || '').trim() || null,
description: String(fd.get('description') || '').trim() || null,
homepageUrl: String(fd.get('homepageUrl') || '').trim() || null,
};
save.disabled = true;
out.innerHTML = '<span class="muted">Saving…</span>';
try {
await api(`/extensions/${encodeURIComponent(ext.id)}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
out.innerHTML = '<span class="success">Saved — listing updated.</span>';
await rerender();
} catch (err) {
out.innerHTML = `<span class="error">${esc(err.message)}</span>`;
save.disabled = false;
}
});
}

/* ---------- detail (extension.html) ---------- */
async function initDetail() {
const slug = qs('slug');
const root = document.getElementById('detail');
if (!slug) { root.innerHTML = '<p class="error">No extension specified.</p>'; return; }
if (qs('paid')) document.getElementById('paidNote')?.classList.remove('hidden');
try {
await renderDetail(slug, root);
} catch (e) {
root.innerHTML = `<p class="error">${e.status === 404 ? 'Extension not found.' : esc(e.message)}</p>`;
}
}

async function renderDetail(slug, root) {
{
const ext = await api(`/extensions/${encodeURIComponent(slug)}`);
const v = ext.version;
const perms = (v?.permissions || []).map((p) => `<span class="perm">${esc(p)}</span>`).join('') || '<span class="muted">none requested</span>';
Expand All @@ -196,7 +264,9 @@ async function initDetail() {
<a class="btn" id="installBtn" href="${esc(dl)}">⬇ Install / Download</a>
<a class="btn secondary" href="/store/install-guide.html">How to install</a>
<button class="btn ghost" id="flagBtn">⚑ Report</button>
${ext.isOwner ? '<button class="btn secondary" id="editBtn">✎ Edit listing</button>' : ''}
</div>
${ext.isOwner ? editForm(ext) : ''}
${ext.homepageUrl ? `<p class="hint">Homepage: <a href="${esc(ext.homepageUrl)}" rel="noopener noreferrer">${esc(ext.homepageUrl)}</a></p>` : ''}
<h3>Permissions</h3>
<div class="perms">${perms}</div>
Expand All @@ -213,8 +283,8 @@ async function initDetail() {
try { await api(`/extensions/${encodeURIComponent(ext.slug)}/flag`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reason }) }); alert('Thanks — flagged for review.'); }
catch (e) { alert('Could not flag: ' + e.message); }
});
} catch (e) {
root.innerHTML = `<p class="error">${e.status === 404 ? 'Extension not found.' : esc(e.message)}</p>`;

wireEditForm(ext, () => renderDetail(slug, root));
}
}

Expand Down
18 changes: 18 additions & 0 deletions scripts/publish-extension.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
# SCP_TARGET default files@files.profullstack.com
# MANIFEST path to manifest.json (default: manifest.json)
# BUNDLE dir to zip, or a .zip/.crx file (default: dist)
# LISTING path to a listing.json — {name?, summary?, description?,
# homepageUrl?, iconUrl?}. Sent as a PATCH after the version
# lands, so the store copy is versioned alongside the code
# instead of frozen at whatever it said the day you created it.
set -euo pipefail

STORE_URL="${STORE_URL:-https://tronbrowser.dev}"
Expand Down Expand Up @@ -61,3 +65,17 @@ resp="$(curl -fsS -X POST "${STORE_URL}/api/store/extensions/${id}/versions" \
-H 'content-type: application/json' -d "$body")"

echo "published ${STORE_SLUG}: $resp"

# 4) Sync the listing copy, if the repo carries one. Publishing a version only
# refreshes the bundle — without this the description keeps describing an
# older release.
if [ -n "${LISTING:-}" ]; then
if [ ! -f "$LISTING" ]; then
echo "error: LISTING='$LISTING' not found" >&2
exit 1
fi
patch="$(curl -fsS -X PATCH "${STORE_URL}/api/store/extensions/${id}" \
-H "authorization: Bearer ${TRONBROWSER_STORE_TOKEN}" \
-H 'content-type: application/json' -d @"$LISTING")"
echo "listing copy synced: $patch"
fi
41 changes: 40 additions & 1 deletion services/api/src/store/db.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
import { describe, expect, it } from 'vitest';
import { boundedInteger } from './db.js';
import { boundedInteger, buildExtensionUpdate } from './db.js';

describe('buildExtensionUpdate', () => {
it('returns null when there is nothing to write', () => {
expect(buildExtensionUpdate({})).toBeNull();
});

it('touches only the fields provided', () => {
const update = buildExtensionUpdate({ summary: 'Now with bulk payouts.' })!;
expect(update.set).toBe("summary = ?, updated_at = datetime('now')");
expect(update.args).toEqual(['Now with bulk payouts.']);
});

it('maps camelCase fields to their columns', () => {
const update = buildExtensionUpdate({ homepageUrl: 'https://example.com', iconUrl: 'data:image/png;base64,AA' })!;
expect(update.set).toBe("homepage_url = ?, icon_url = ?, updated_at = datetime('now')");
expect(update.args).toEqual(['https://example.com', 'data:image/png;base64,AA']);
});

it('distinguishes clearing a field from leaving it alone', () => {
const cleared = buildExtensionUpdate({ summary: null })!;
expect(cleared.args).toEqual([null]);
// `description` absent entirely — must not appear in the SET clause.
expect(cleared.set).not.toContain('description');
});

it('always stamps updated_at so a copy edit is visible on the listing', () => {
const update = buildExtensionUpdate({ description: 'x' })!;
expect(update.set.endsWith("updated_at = datetime('now')")).toBe(true);
// updated_at is inlined SQL, not a bound arg.
expect(update.args).toHaveLength(1);
});

it('orders columns predictably regardless of key order', () => {
const a = buildExtensionUpdate({ description: 'd', name: 'n' })!;
const b = buildExtensionUpdate({ name: 'n', description: 'd' })!;
expect(a.set).toBe(b.set);
expect(a.args).toEqual(b.args);
});
});

describe('boundedInteger', () => {
it('falls back for non-finite pagination values', () => {
Expand Down
54 changes: 54 additions & 0 deletions services/api/src/store/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,60 @@ export async function createExtension(x: {
return (await extensionById(id))!;
}

/** Editable listing copy. `undefined` leaves a column alone; `null` clears it. */
export interface ExtensionPatch {
name?: string | null;
summary?: string | null;
description?: string | null;
homepageUrl?: string | null;
iconUrl?: string | null;
}

const PATCH_COLUMNS: ReadonlyArray<[keyof ExtensionPatch, string]> = [
['name', 'name'],
['summary', 'summary'],
['description', 'description'],
['homepageUrl', 'homepage_url'],
['iconUrl', 'icon_url'],
];

/**
* Build the SET clause for a listing patch, skipping absent fields. Returns
* null when there is nothing to write, so callers can avoid a pointless UPDATE.
* Exported for tests — the SQL shape is the part worth pinning down.
*/
export function buildExtensionUpdate(patch: ExtensionPatch): { set: string; args: (string | null)[] } | null {
const set: string[] = [];
const args: (string | null)[] = [];

for (const [key, column] of PATCH_COLUMNS) {
const value = patch[key];
if (value === undefined) continue;
set.push(`${column} = ?`);
args.push(value === null ? null : String(value));
}

if (set.length === 0) return null;
set.push("updated_at = datetime('now')");
return { set: set.join(', '), args };
}

/**
* Update a listing's copy. Without this a listing is write-once at creation:
* every later publish refreshes the bundle but leaves the marketing text
* frozen, so a listing keeps advertising whatever the extension did on day one.
*/
export async function updateExtension(id: string, patch: ExtensionPatch): Promise<Extension | null> {
const update = buildExtensionUpdate(patch);
if (!update) return extensionById(id);

await db().execute({
sql: `UPDATE extensions SET ${update.set} WHERE id = ?`,
args: [...update.args, id],
});
return extensionById(id);
}

export async function extensionById(id: string): Promise<Extension | null> {
const r = await db().execute({ sql: 'SELECT * FROM extensions WHERE id = ?', args: [id] });
return (r.rows[0] as unknown as Extension) ?? null;
Expand Down
48 changes: 45 additions & 3 deletions services/api/src/store/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
markPaidByRef, hasPaidListing, latestScan, addFlag, openFlagCount,
publisherKey, handleTaken, upsertPublisherKey,
createPublisherToken, userByPublisherToken, listPublisherTokens, revokePublisherToken,
updateExtension,
} from './db.js';
import { validateManifest, slugify } from './manifest.js';
import {
Expand Down Expand Up @@ -50,7 +51,9 @@ function xmlEscape(s: string): string {
}

// Make a public listing view (no owner internals), with scan + flag summary.
async function listingView(ext: any) {
// `viewer` is optional: when the signed-in user owns the listing we say so, so
// the detail page can offer an edit form instead of making them reach for curl.
async function listingView(ext: any, viewer?: User | null) {
const [ver, scan, flags] = await Promise.all([
latestVersion(ext.id),
latestScan(ext.id),
Expand All @@ -77,6 +80,7 @@ async function listingView(ext: any) {
} : null,
scan: scan ? { status: scan.status, score: scan.score, severity: scan.severity } : null,
flags,
isOwner: !!viewer && viewer.id === ext.owner_user_id,
updateUrl: `${APP_URL}/api/store/updates.xml?id=${ext.id}`,
};
}
Expand All @@ -91,13 +95,51 @@ store.get('/extensions', async (c) => {
const limit = Number(c.req.query('limit') || 50);
const offset = Number(c.req.query('offset') || 0);
const rows = await listLiveExtensions({ q, limit, offset });
return c.json({ extensions: await Promise.all(rows.map(listingView)) });
// Point-free .map would hand the array index in as `viewer`.
return c.json({ extensions: await Promise.all(rows.map((row) => listingView(row))) });
});

store.get('/extensions/:slug', async (c) => {
const ext = await extensionBySlug(c.req.param('slug'));
if (!ext || ext.status === 'removed') return c.json({ error: 'not found' }, 404);
return c.json(await listingView(ext));
return c.json(await listingView(ext, await currentUser(c).catch(() => null)));
});

/* ---------- publisher: edit listing copy ----------
Creation used to be the only chance to set a listing's text, so a listing
kept describing whatever the extension did on day one while every later
publish quietly refreshed the bundle underneath it. Owners (browser session
or CI publisher token) can now keep the copy honest. */
store.patch('/extensions/:id', async (c) => {
const user = await currentUser(c);
if (!user) return c.json({ error: 'unauthorized' }, 401);
const ext = await extensionById(c.req.param('id'));
if (!ext) return c.json({ error: 'not found' }, 404);
if (ext.owner_user_id !== user.id) return c.json({ error: 'forbidden' }, 403);

const body = await c.req.json().catch(() => ({}));
const patch: Record<string, string | null> = {};
for (const field of ['name', 'summary', 'description', 'homepageUrl', 'iconUrl'] as const) {
if (!(field in body)) continue;
const raw = body[field];
if (raw === null) { patch[field] = null; continue; }
if (typeof raw !== 'string') return c.json({ error: `${field} must be a string or null` }, 400);
const trimmed = raw.trim();
// A blank name would leave the listing untitled; every other field may clear.
if (field === 'name' && !trimmed) return c.json({ error: 'name cannot be empty' }, 400);
patch[field] = trimmed || null;
}

if (Object.keys(patch).length === 0) return c.json({ error: 'nothing to update' }, 400);

const updated = await updateExtension(ext.id, patch);
if (!updated) return c.json({ error: 'not found' }, 404);

// Keep the public git trail in step with what the store now serves.
const ver = await latestVersion(updated.id);
if (ver && updated.status === 'live') await mirrorListing(updated, ver);

return c.json({ ok: true, listing: await listingView(updated) });
});

/* ---------- publisher: create draft ---------- */
Expand Down
Loading