215215 font-size : 13px ;
216216 z-index : 200 ;
217217 }
218+ .header-actions {
219+ display : flex;
220+ align-items : center;
221+ gap : 16px ;
222+ }
223+ .settings-btn {
224+ background : none;
225+ border : none;
226+ color : # fff ;
227+ cursor : pointer;
228+ font-size : 18px ;
229+ padding : 4px 8px ;
230+ opacity : 0.8 ;
231+ }
232+ .settings-btn : hover { opacity : 1 ; }
233+ .test-btn {
234+ background : # 238636 ;
235+ border : none;
236+ color : # fff ;
237+ padding : 6px 12px ;
238+ border-radius : 4px ;
239+ cursor : pointer;
240+ font-size : 12px ;
241+ }
242+ .test-btn : hover { background : # 2ea043 ; }
243+ .test-btn : disabled { opacity : 0.5 ; cursor : not-allowed; }
244+ .pubkey-display {
245+ font-family : monospace;
246+ font-size : 11px ;
247+ background : # f6f8fa ;
248+ padding : 8px ;
249+ border-radius : 4px ;
250+ word-break : break-all;
251+ margin-top : 4px ;
252+ }
253+ .test-result {
254+ margin-top : 12px ;
255+ padding : 8px ;
256+ border-radius : 4px ;
257+ font-size : 12px ;
258+ }
259+ .test-result .success { background : # dafbe1 ; color : # 1a7f37 ; }
260+ .test-result .error { background : # ffebe9 ; color : # cf222e ; }
218261 </ style >
219262</ head >
220263< body >
228271 import git from 'https://esm.sh/isomorphic-git@1.25.3' ;
229272 import http from 'https://esm.sh/isomorphic-git@1.25.3/http/web' ;
230273 import LightningFS from 'https://esm.sh/@isomorphic-git/lightning-fs@4.6.0' ;
274+ import { schnorr } from 'https://esm.sh/@noble/curves@1.3.0/secp256k1' ;
275+ import { bytesToHex , hexToBytes } from 'https://esm.sh/@noble/hashes@1.3.3/utils' ;
276+ import { sha256 } from 'https://esm.sh/@noble/hashes@1.3.3/sha256' ;
231277
232278 const html = htm . bind ( h ) ;
233279
280+ // === NIP-98 Auth ===
281+ function getPrivkey ( ) {
282+ return localStorage . getItem ( 'nostr-git-privkey' ) || '' ;
283+ }
284+
285+ function setPrivkey ( key ) {
286+ localStorage . setItem ( 'nostr-git-privkey' , key ) ;
287+ }
288+
289+ function getPubkeyFromPrivkey ( privkey ) {
290+ try {
291+ const pubkeyBytes = schnorr . getPublicKey ( privkey ) ;
292+ return bytesToHex ( pubkeyBytes ) ;
293+ } catch {
294+ return '' ;
295+ }
296+ }
297+
298+ function serializeEvent ( event ) {
299+ return JSON . stringify ( [
300+ 0 ,
301+ event . pubkey ,
302+ event . created_at ,
303+ event . kind ,
304+ event . tags ,
305+ event . content
306+ ] ) ;
307+ }
308+
309+ function getEventHash ( event ) {
310+ const serialized = serializeEvent ( event ) ;
311+ const encoder = new TextEncoder ( ) ;
312+ return bytesToHex ( sha256 ( encoder . encode ( serialized ) ) ) ;
313+ }
314+
315+ async function signEvent ( event , privkey ) {
316+ const pubkey = getPubkeyFromPrivkey ( privkey ) ;
317+ const eventWithPubkey = { ...event , pubkey } ;
318+ const id = getEventHash ( eventWithPubkey ) ;
319+ const sig = bytesToHex ( schnorr . sign ( id , privkey ) ) ;
320+ return { ...eventWithPubkey , id, sig } ;
321+ }
322+
323+ async function createNip98Token ( url , method , privkey ) {
324+ const event = {
325+ kind : 27235 ,
326+ created_at : Math . floor ( Date . now ( ) / 1000 ) ,
327+ tags : [
328+ [ 'u' , url ] ,
329+ [ 'method' , method ]
330+ ] ,
331+ content : ''
332+ } ;
333+ const signed = await signEvent ( event , privkey ) ;
334+ return 'Nostr ' + btoa ( JSON . stringify ( signed ) ) ;
335+ }
336+
337+ async function nip98Fetch ( url , method , privkey , body = null ) {
338+ const auth = await createNip98Token ( url , method , privkey ) ;
339+ const options = {
340+ method,
341+ headers : {
342+ 'Authorization' : auth
343+ }
344+ } ;
345+ if ( body !== null ) {
346+ options . body = body ;
347+ options . headers [ 'Content-Type' ] = 'text/plain' ;
348+ }
349+ return fetch ( url , options ) ;
350+ }
351+
234352 // === Storage ===
235353 const fs = new LightningFS ( 'nostr-git-browser' ) ;
236354 const pfs = fs . promises ;
394512 }
395513
396514 // === Components ===
397- function Header ( { relayStatus } ) {
515+ function Header ( { relayStatus, onSettings } ) {
398516 return html `
399517 < header >
400518 < h1 > 🔗 Nostr Git Browser</ h1 >
401- < div class ="status ">
402- ${ relayStatus . map ( r => html `
403- < span key =${ r . url } >
404- < span class ="status-dot ${ r . status } "> </ span >
405- ${ new URL ( r . url ) . host }
406- </ span >
407- ` ) }
519+ < div class ="header-actions ">
520+ < div class ="status ">
521+ ${ relayStatus . map ( r => html `
522+ < span key =${ r . url } >
523+ < span class ="status-dot ${ r . status } "> </ span >
524+ ${ new URL ( r . url ) . host }
525+ </ span >
526+ ` ) }
527+ </ div >
528+ < button class ="settings-btn " onClick =${ onSettings } title ="Settings"> ⚙️</ button >
408529 </ div >
409530 </ header >
410531 ` ;
@@ -567,6 +688,112 @@ <h2>Add Repository</h2>
567688 return html `< div class ="toast "> ${ message } </ div > ` ;
568689 }
569690
691+ function SettingsModal ( { onClose, onToast } ) {
692+ const [ privkey , setPrivkeyState ] = useState ( getPrivkey ) ;
693+ const [ testUrl , setTestUrl ] = useState ( 'https://melvin.me/public/git/sync/20260202/counter.txt' ) ;
694+ const [ testResult , setTestResult ] = useState ( null ) ;
695+ const [ testing , setTesting ] = useState ( false ) ;
696+
697+ const pubkey = useMemo ( ( ) => getPubkeyFromPrivkey ( privkey ) , [ privkey ] ) ;
698+ const isValid = pubkey . length === 64 ;
699+
700+ const handleSave = ( ) => {
701+ setPrivkey ( privkey ) ;
702+ onToast ( 'Settings saved' ) ;
703+ onClose ( ) ;
704+ } ;
705+
706+ const handleTest = async ( ) => {
707+ if ( ! isValid ) {
708+ setTestResult ( { success : false , message : 'Invalid private key' } ) ;
709+ return ;
710+ }
711+
712+ setTesting ( true ) ;
713+ setTestResult ( null ) ;
714+
715+ try {
716+ // First GET the current value
717+ let currentValue = 0 ;
718+ try {
719+ const getRes = await fetch ( testUrl + '?' + Date . now ( ) ) ;
720+ if ( getRes . ok ) {
721+ const text = await getRes . text ( ) ;
722+ currentValue = parseInt ( text . trim ( ) ) || 0 ;
723+ }
724+ } catch ( e ) {
725+ // File doesn't exist yet, start at 0
726+ }
727+
728+ // PUT the incremented value
729+ const newValue = currentValue + 1 ;
730+ const res = await nip98Fetch ( testUrl , 'PUT' , privkey , String ( newValue ) ) ;
731+
732+ if ( res . ok ) {
733+ setTestResult ( { success : true , message : `Success! Counter: ${ currentValue } → ${ newValue } ` } ) ;
734+ } else {
735+ const text = await res . text ( ) ;
736+ setTestResult ( { success : false , message : `HTTP ${ res . status } : ${ text } ` } ) ;
737+ }
738+ } catch ( err ) {
739+ setTestResult ( { success : false , message : err . message } ) ;
740+ } finally {
741+ setTesting ( false ) ;
742+ }
743+ } ;
744+
745+ return html `
746+ < div class ="modal-overlay " onClick =${ onClose } >
747+ < div class ="modal " onClick =${ e => e . stopPropagation ( ) } >
748+ < h2 > Settings</ h2 >
749+ < label >
750+ < span > Private Key (hex)</ span >
751+ < input
752+ type ="password "
753+ value =${ privkey }
754+ onInput =${ e => setPrivkeyState ( e . target . value ) }
755+ placeholder="64 character hex private key"
756+ />
757+ </ label >
758+ ${ privkey && html `
759+ < div >
760+ < span style ="font-size: 12px; color: #656d76; "> Public Key:</ span >
761+ < div class ="pubkey-display ${ isValid ? '' : 'error' } ">
762+ ${ isValid ? pubkey : 'Invalid private key' }
763+ </ div >
764+ </ div >
765+ ` }
766+ < hr style ="margin: 16px 0; border: none; border-top: 1px solid #d0d7de; " />
767+ < label >
768+ < span > Test URL (PUT)</ span >
769+ < input
770+ value =${ testUrl }
771+ onInput =${ e => setTestUrl ( e . target . value ) }
772+ placeholder="https://melvin.me/path/to/file.txt"
773+ />
774+ </ label >
775+ < button
776+ class ="test-btn "
777+ onClick =${ handleTest }
778+ disabled =${ ! isValid || testing }
779+ style="margin-top: 8px;"
780+ >
781+ ${ testing ? 'Testing...' : 'Test NIP-98 PUT' }
782+ </ button >
783+ ${ testResult && html `
784+ < div class ="test-result ${ testResult . success ? 'success' : 'error' } ">
785+ ${ testResult . message }
786+ </ div >
787+ ` }
788+ < div class ="buttons ">
789+ < button type ="button " onClick =${ onClose } > Cancel</ button >
790+ < button type ="submit " onClick =${ handleSave } > Save</ button >
791+ </ div >
792+ </ div >
793+ </ div >
794+ ` ;
795+ }
796+
570797 // === Main App ===
571798 function App ( ) {
572799 const [ config , setConfig ] = useState ( loadConfig ) ;
@@ -578,6 +805,7 @@ <h2>Add Repository</h2>
578805 const [ fileContent , setFileContent ] = useState ( '' ) ;
579806 const [ localFiles , setLocalFiles ] = useState ( { } ) ;
580807 const [ showAddModal , setShowAddModal ] = useState ( false ) ;
808+ const [ showSettingsModal , setShowSettingsModal ] = useState ( false ) ;
581809 const [ relayStatus , setRelayStatus ] = useState ( [ ] ) ;
582810 const [ toast , setToast ] = useState ( '' ) ;
583811
@@ -759,7 +987,7 @@ <h2>Add Repository</h2>
759987
760988 return html `
761989 < div class ="app ">
762- < ${ Header } relayStatus =${ relayStatus } />
990+ < ${ Header } relayStatus =${ relayStatus } onSettings= ${ ( ) => setShowSettingsModal ( true ) } />
763991 < div class ="main ">
764992 < ${ RepoList }
765993 repos =${ config . repos }
@@ -791,6 +1019,12 @@ <h2>Add Repository</h2>
7911019 onClose=${ ( ) => setShowAddModal ( false ) }
7921020 />
7931021 ` }
1022+ ${ showSettingsModal && html `
1023+ < ${ SettingsModal }
1024+ onClose =${ ( ) => setShowSettingsModal ( false ) }
1025+ onToast=${ showToast }
1026+ />
1027+ ` }
7941028 < ${ Toast } message =${ toast } />
7951029 </ div >
7961030 ` ;
0 commit comments