@@ -2349,7 +2349,7 @@ function setMsg(text){const m=document.getElementById('form-msg');if(m)m.textCon
23492349function shortId(id){return id.length>28?id.slice(0,16)+'…'+id.slice(-6):id}
23502350function 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())})}
23532353function 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+ }});
25152559document.addEventListener('xlogin',function(){renderAuth();wireForms()});
25162560document.addEventListener('xlogout',function(){renderAuth();wireForms()});
25172561if(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 ;
0 commit comments