Skip to content

Commit eadd451

Browse files
forge: Edit button on issue/PR entries (author-only)
Per-entry Edit beside Delete: fetch the raw doc from its resourceUrl, swap the rendered body for a textarea, PUT the doc back with only its body changed. Author-only, server-enforced — pod WAC for WebID authors, and a new author-only PUT branch on /api/hosted/<hex>/<uuid> for did:nostr words (GET stays public, DELETE unchanged). The hosted PUT keeps every field from the stored doc and only overwrites body, so a signed request can't rewrite authorship; it stamps an `edited` time. Body buffered before auth so the NIP-98 payload tag verifies. 133 green (+ wrong-key 403, author-edit-preserves-fields, edit-button render).
1 parent 3cb8a62 commit eadd451

2 files changed

Lines changed: 100 additions & 4 deletions

File tree

forge/plugin.js

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2349,7 +2349,7 @@ function setMsg(text){const m=document.getElementById('form-msg');if(m)m.textCon
23492349
function shortId(id){return id.length>28?id.slice(0,16)+'…'+id.slice(-6):id}
23502350
function wireForms(){for(const id of ['submit-issue','submit-comment','toggle-state','submit-pull','do-merge','do-enable','submit-txo']){
23512351
const b=document.getElementById(id);if(b)b.disabled=!(T()||X())}
2352-
document.querySelectorAll('.del-entry').forEach(function(b){b.disabled=!(T()||X())})}
2352+
document.querySelectorAll('.del-entry,.edit-entry').forEach(function(b){b.disabled=!(T()||X())})}
23532353
function renderAuth(){
23542354
const box=document.getElementById('forge-auth');if(!box)return;
23552355
box.textContent='';
@@ -2512,6 +2512,50 @@ document.querySelectorAll('.del-entry').forEach(function(b){b.onclick=async func
25122512
location.reload();
25132513
}catch(e){setMsg(String(e&&e.message||e));b.disabled=was}
25142514
}});
2515+
// Edit: GET the raw doc from its resourceUrl, swap the rendered body for a
2516+
// textarea, PUT the doc back with only its body changed. Author-only,
2517+
// enforced server-side (pod WAC, or the hosted PUT endpoint did:nostr check).
2518+
async function authRead(url){
2519+
let res;
2520+
if(X()){res=await window.xlogin.authFetch(url)}
2521+
else{res=await fetch(url,{headers:{authorization:'Bearer '+T()}})}
2522+
if(!res.ok)throw new Error('HTTP '+res.status);
2523+
return res.json();
2524+
}
2525+
document.querySelectorAll('.edit-entry').forEach(function(b){b.onclick=async function(){
2526+
const url=b.getAttribute('data-edit');if(!url)return;
2527+
const box=b.closest('.cbox');if(!box)return;
2528+
const md=box.querySelector('.markdown-body');if(!md||box.querySelector('.edit-wrap'))return;
2529+
setMsg('');b.disabled=true;
2530+
let doc;
2531+
try{doc=await authRead(url)}
2532+
catch(e){setMsg('Could not load for editing: '+String(e&&e.message||e));b.disabled=false;return}
2533+
const cur=(doc&&typeof doc.body==='string')?doc.body:'';
2534+
md.style.display='none';
2535+
const ta=el('textarea',{value:cur});
2536+
ta.style.width='100%';ta.style.minHeight='140px';ta.style.boxSizing='border-box';ta.style.fontFamily='inherit';
2537+
const save=el('button',{className:'btn btn-primary',type:'button'},'Save');
2538+
const cancel=el('button',{className:'btn',type:'button'},'Cancel');
2539+
const bar=el('div',{},save,' ',cancel);bar.style.margin='8px 0';
2540+
const wrap=el('div',{className:'edit-wrap'},ta,bar);
2541+
md.parentNode.insertBefore(wrap,md.nextSibling);
2542+
ta.focus();
2543+
function done(){wrap.remove();md.style.display='';b.disabled=false}
2544+
cancel.onclick=done;
2545+
save.onclick=async function(){
2546+
save.disabled=true;cancel.disabled=true;setMsg('Saving…');
2547+
try{
2548+
const isHosted=b.getAttribute('data-hosted')==='1';
2549+
const next=Object.assign({},doc,{body:ta.value});
2550+
const ct=isHosted?'application/json':'application/ld+json';
2551+
let res;
2552+
if(X()){res=await window.xlogin.authFetch(url,{method:'PUT',headers:{'content-type':ct},body:JSON.stringify(next)})}
2553+
else{res=await fetch(url,{method:'PUT',headers:{'content-type':ct,authorization:'Bearer '+T()},body:JSON.stringify(next)})}
2554+
if(!(res.ok||res.status===204))throw new Error(res.status===403?'only the author can edit this':'HTTP '+res.status);
2555+
location.reload();
2556+
}catch(e){setMsg(String(e&&e.message||e));save.disabled=false;cancel.disabled=false}
2557+
};
2558+
}});
25152559
document.addEventListener('xlogin',function(){renderAuth();wireForms()});
25162560
document.addEventListener('xlogout',function(){renderAuth();wireForms()});
25172561
if(window.xlogin&&window.xlogin.ready)window.xlogin.ready.then(function(){renderAuth();wireForms()});
@@ -2576,7 +2620,11 @@ ${pager}
25762620
// user like the close/reopen button; deleting shows the existing tombstone.
25772621
const del = (e.removed || !e.resourceUrl) ? ''
25782622
: `<button class="btn del-entry" type="button" data-del="${esc(e.resourceUrl)}" disabled title="Delete (author only)" style="float:right;margin:6px;padding:2px 8px;font-size:12px">Delete</button>`;
2579-
const head = `${del}${identicon(who)} <a href="${esc(authorHref(e.author))}"><b>${esc(who)}</b></a>${ownerBadge}${hostedTag}
2623+
// Author-only Edit: fetch the raw body from resourceUrl, PUT it back
2624+
// (pod WAC for WebID authors; the hosted PUT endpoint for did:nostr).
2625+
const edit = (e.removed || !e.resourceUrl) ? ''
2626+
: `<button class="btn edit-entry" type="button" data-edit="${esc(e.resourceUrl)}" data-hosted="${e.hosted ? '1' : ''}" disabled title="Edit (author only)" style="float:right;margin:6px;padding:2px 8px;font-size:12px">Edit</button>`;
2627+
const head = `${del}${edit}${identicon(who)} <a href="${esc(authorHref(e.author))}"><b>${esc(who)}</b></a>${ownerBadge}${hostedTag}
25802628
<span class="muted">${i === 0 ? openVerb : 'commented'} ${relTime(e.at)}</span>`;
25812629
const slot = e.removed
25822630
? '<div class="removed">content removed by its author</div>'
@@ -4394,8 +4442,10 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, owner, name, base,
43944442

43954443
/**
43964444
* <prefix>/api/hosted/<hex>/<uuid> — a podless agent's words, hosted by
4397-
* the forge. GET is public (like a public pod resource); DELETE is the
4398-
* author-only removal beat (same did:nostr identity that wrote it).
4445+
* the forge. GET is public (like a public pod resource); PUT (edit) and
4446+
* DELETE (removal) are author-only — the same did:nostr identity that
4447+
* wrote it. Editing only ever changes `body`; every other field is kept
4448+
* from the stored doc, so a signed request can't rewrite authorship.
43994449
*/
44004450
async function apiHosted(request, reply, segs) {
44014451
if (segs.length !== 2 || !NOSTR_HEX.test(segs[0]) || !UUID_RE.test(segs[1])) {
@@ -4410,6 +4460,21 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, owner, name, base,
44104460
const doc = readHosted(hex, id);
44114461
return doc ? sendJson(reply, 200, doc) : apiErr(reply, 404, 'not found');
44124462
}
4463+
if (request.method === 'PUT' || request.method === 'PATCH') {
4464+
const incoming = await readJsonBody(request); // buffer before auth (NIP-98 payload tag)
4465+
const agent = await apiAgent(request, reply);
4466+
if (!agent) return reply;
4467+
if (agent !== `did:nostr:${hex}`) return apiErr(reply, 403, 'only the author may edit hosted content');
4468+
const existing = readHosted(hex, id);
4469+
if (!existing) return apiErr(reply, 404, 'not found');
4470+
const body = incoming && typeof incoming.body === 'string' ? incoming.body : null;
4471+
if (body === null) return apiErr(reply, 400, 'body (string) required');
4472+
if (body.length > ISSUE_BODY_CAP) return apiErr(reply, 413, 'body too large');
4473+
const updated = { ...existing, body, edited: new Date().toISOString() };
4474+
fs.writeFileSync(hostedPathOf(hex, id), JSON.stringify(updated));
4475+
api.log.info(`forge: hosted content ${hex}/${id} edited by its author`);
4476+
return sendJson(reply, 200, { edited: true });
4477+
}
44134478
if (request.method === 'DELETE') {
44144479
const agent = await apiAgent(request, reply);
44154480
if (!agent) return reply;

forge/test.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,37 @@ describe('forge plugin', () => {
10201020
assert.ok(html.includes(`/.well-known/did/nostr/${pkA}`), "author links to core's DID-document route");
10211021
assert.ok(html.includes('>owner</span>'), 'hex-namespace owner badge still works');
10221022
assert.match(html, /class="btn del-entry"[^>]*data-del="[^"]*\/api\/hosted\//, 'per-entry Delete button renders pointing at the resource URL');
1023+
assert.match(html, /class="btn edit-entry"[^>]*data-edit="[^"]*\/api\/hosted\//, 'per-entry Edit button renders pointing at the resource URL');
1024+
});
1025+
1026+
it("another agent cannot edit someone else's hosted content (403)", async () => {
1027+
const putBody = JSON.stringify({ body: 'not my words to change' });
1028+
const res = await fetch(hostedIssueUrl, {
1029+
method: 'PUT',
1030+
headers: { 'content-type': 'application/json', authorization: nip98Header(skB, hostedIssueUrl, 'PUT', putBody) },
1031+
body: putBody,
1032+
});
1033+
assert.strictEqual(res.status, 403);
1034+
const doc = await (await fetch(hostedIssueUrl)).json();
1035+
assert.ok(doc.body.includes('**schnorr**'), 'original body untouched');
1036+
});
1037+
1038+
it('the author edits their hosted content -> only body changes, edited stamp added', async () => {
1039+
const putBody = JSON.stringify({ body: 'edited: now with *more* schnorr', author: `did:nostr:${pkB}`, hosted: false });
1040+
const res = await fetch(hostedIssueUrl, {
1041+
method: 'PUT',
1042+
headers: { 'content-type': 'application/json', authorization: nip98Header(skA, hostedIssueUrl, 'PUT', putBody) },
1043+
body: putBody,
1044+
});
1045+
assert.strictEqual(res.status, 200);
1046+
assert.strictEqual((await res.json()).edited, true);
1047+
const doc = await (await fetch(hostedIssueUrl)).json();
1048+
assert.strictEqual(doc.body, 'edited: now with *more* schnorr', 'new body persisted');
1049+
assert.strictEqual(doc.author, didA, 'author NOT rewritten by the incoming payload');
1050+
assert.strictEqual(doc.hosted, true, 'hosted flag NOT rewritten by the incoming payload');
1051+
assert.ok(typeof doc.edited === 'string', 'edited timestamp recorded');
1052+
const t = await (await fetch(`${base}/forge/api/repos/${pkA}/nrepo/issues/1`)).json();
1053+
assert.ok(t.thread[0].html.includes('<em>more</em>'), 'edited markdown re-renders in the thread');
10231054
});
10241055

10251056
it("another agent cannot delete someone else's hosted content (403)", async () => {

0 commit comments

Comments
 (0)