| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "html/template" |
| 7 | "io" |
| 8 | "net/http" |
| 9 | "net/url" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/FelineStateMachine/tinyrelay/internal/catalog" |
| 14 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 15 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 16 | "github.com/FelineStateMachine/tinyrelay/internal/templates" |
| 17 | "github.com/FelineStateMachine/tinyrelay/protocol/auth" |
| 18 | ) |
| 19 | |
| 20 | // ServeLanding handles only the process landing page and permanent relay |
| 21 | // creation. It returns false for tenant routes so App.ServeHTTP can continue |
| 22 | // with normal host and /r/<name> resolution. |
| 23 | func (a *App) ServeLanding(w http.ResponseWriter, r *http.Request) bool { |
| 24 | if r.URL.Path != "/" && r.URL.Path != "/relays" { |
| 25 | return false |
| 26 | } |
| 27 | if r.Method == http.MethodGet && r.URL.Path == "/" { |
| 28 | a.landingPage(w, r) |
| 29 | return true |
| 30 | } |
| 31 | if r.Method == http.MethodGet && r.URL.Path == "/relays" { |
| 32 | a.relayList(w, r) |
| 33 | return true |
| 34 | } |
| 35 | if r.Method == http.MethodPost && r.URL.Path == "/relays" { |
| 36 | a.createRelay(w, r) |
| 37 | return true |
| 38 | } |
| 39 | http.Error(w, "method not allowed" , http.StatusMethodNotAllowed) |
| 40 | return true |
| 41 | } |
| 42 | |
| 43 | type landingRelay struct{ Name, URL, Status string } |
| 44 | |
| 45 | func (a *App) landingPage(w http.ResponseWriter, r *http.Request) { |
| 46 | relays, err := a.publicRelays(r.Context(), r) |
| 47 | if err != nil { |
| 48 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 49 | return |
| 50 | } |
| 51 | tmpl := template.Must(template.New("landing" ).Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>tiny relays</title><style>html,body{margin:0;padding:0}body{font:16px system-ui,sans-serif;line-height:1.4}.shell{width:360px;margin:1rem auto}.shell td{vertical-align:top}.content{padding:0 1rem}table{border-collapse:collapse;table-layout:fixed;width:100%}th,td{border:1px solid;padding:.35rem;text-align:left;overflow-wrap:anywhere}@media(min-width:979px){.shell{width:960px}}</style></head><body><table class="shell" role="presentation"><tr><td class="content"><h1>tiny relays</h1><p>Self-hosted relays with permanent ownership.</p><p><a href="/relays">Create a relay</a></p><h2>Public relays</h2>{{if .}}<table><thead><tr><th>Name</th><th>Status</th></tr></thead><tbody>{{range .}}<tr><td><a href="{{.URL}}">{{.Name}}</a></td><td>{{.Status}}</td></tr>{{end}}</tbody></table>{{else}}<p>No public relays are listed.</p>{{end}}</td></tr></table></body></html>` )) |
| 52 | w.Header().Set("Content-Type" , "text/html; charset=utf-8" ) |
| 53 | _ = tmpl.Execute(w, relays) |
| 54 | } |
| 55 | |
| 56 | func (a *App) relayList(w http.ResponseWriter, r *http.Request) { |
| 57 | if r.Method != http.MethodGet { |
| 58 | http.Error(w, "method not allowed" , http.StatusMethodNotAllowed) |
| 59 | return |
| 60 | } |
| 61 | tmpl := template.Must(template.New("create" ).Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Create relay</title><style>html,body{margin:0;padding:0}body{font:16px system-ui,sans-serif;line-height:1.4}.shell{width:360px;margin:1rem auto}.shell td{vertical-align:top}.content{padding:0 1rem}table{border-collapse:collapse;table-layout:fixed;width:100%}th,td{border:1px solid;padding:.35rem;text-align:left;overflow-wrap:anywhere}input,select,button{font:inherit;max-width:100%;box-sizing:border-box}@media(min-width:979px){.shell{width:960px}}</style></head><body><table class="shell" role="presentation"><tr><td class="content"><h1>Create a permanent relay</h1><p>Use a NIP-07 browser signer. Ownership is assigned to the signer.</p><p>Browser provisioning is restricted to operator-approved owners. The command line remains available to the host operator.</p><form id="create" method="post" action="/relays"><table><tr><th>Name</th><td><input name="name" required pattern="[a-z0-9-]+"></td></tr><tr><th>Template</th><td><select name="template">{{range .Templates}}<option value="{{.}}">{{.}}</option>{{end}}</select></td></tr><tr><th>Source relay</th><td><input name="source" type="url" placeholder="wss://relay.example"></td></tr></table><input type="hidden" name="owner"><button>Create relay</button></form><p id="status"></p><script> |
| 62 | const form=document.getElementById('create' ),status=document.getElementById('status' );form.addEventListener('submit' ,async e=>{e.preventDefault();try{if(!window.nostr)throw Error('No NIP-07 signer found' );const owner=await window.nostr.getPublicKey();const body=JSON.stringify({name:form.name.value,template:form.template.value,owner,source:form.source.value});const digest=await crypto.subtle.digest('SHA-256' ,new TextEncoder().encode(body));const hash=[...new Uint8Array(digest)].map(x=>x.toString(16).padStart(2,'0' )).join('' );const signed=await window.nostr.signEvent({kind:27235,created_at:Math.floor(Date.now()/1000),tags:[['u' ,location.origin+'/relays' ],['method' ,'POST' ],['payload' ,hash]],content:'' });const token=btoa(unescape(encodeURIComponent(JSON.stringify(signed))));const response=await fetch('/relays' ,{method:'POST' ,headers:{'Content-Type' :'application/json' ,'Authorization' :'Nostr ' +token},body});if(!response.ok)throw Error(await response.text());location.href=(await response.json()).url}catch(err){status.textContent=err.message}});</script><p><a href="/" >Back</a></p></td></tr></table></body></html>`)) |
| 63 | w.Header().Set("Content-Type" , "text/html; charset=utf-8" ) |
| 64 | _ = tmpl.Execute(w, struct{ Templates []string }{Templates: templates.Names()}) |
| 65 | } |
| 66 | |
| 67 | func (a *App) createRelay(w http.ResponseWriter, r *http.Request) { |
| 68 | reader := io.Reader(r.Body) |
| 69 | if a.cfg.MaxMessageBytes > 0 { |
| 70 | reader = http.MaxBytesReader(w, r.Body, a.cfg.MaxMessageBytes) |
| 71 | } |
| 72 | body, err := io.ReadAll(reader) |
| 73 | if err != nil { |
| 74 | http.Error(w, err.Error(), 400) |
| 75 | return |
| 76 | } |
| 77 | var input struct { |
| 78 | Name string `json:"name"` |
| 79 | Template string `json:"template"` |
| 80 | Owner string `json:"owner"` |
| 81 | Source string `json:"source"` |
| 82 | } |
| 83 | if err := json.Unmarshal(body, &input); err != nil { |
| 84 | r.Body = io.NopCloser(strings.NewReader(string(body))) |
| 85 | _ = r.ParseForm() |
| 86 | input.Name, input.Template, input.Owner, input.Source = r.FormValue("name" ), r.FormValue("template" ), r.FormValue("owner" ), r.FormValue("source" ) |
| 87 | } |
| 88 | if input.Template == "" { |
| 89 | input.Template = "default" |
| 90 | } |
| 91 | if input.Source != "" { |
| 92 | u, parseErr := url.Parse(input.Source) |
| 93 | if parseErr != nil || u.Host == "" || u.User != nil || (u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "ws" && u.Scheme != "wss" ) { |
| 94 | http.Error(w, "source must be an http, https, ws, or wss relay" , 400) |
| 95 | return |
| 96 | } |
| 97 | } |
| 98 | requestedURL := a.publicURL(r, "" ) + "/relays" |
| 99 | validator := auth.NewValidator(time.Now) |
| 100 | proof, err := validator.VerifyNIP98(r.Header.Get("Authorization" ), requestedURL, r.Method, string(body)) |
| 101 | if err != nil { |
| 102 | http.Error(w, err.Error(), 401) |
| 103 | return |
| 104 | } |
| 105 | if proof.PubKey != input.Owner { |
| 106 | http.Error(w, "owner must match the signer" , 403) |
| 107 | return |
| 108 | } |
| 109 | if !a.provisionAllowed(proof.PubKey) { |
| 110 | http.Error(w, "browser provisioning is disabled for this owner" , http.StatusForbidden) |
| 111 | return |
| 112 | } |
| 113 | consumed, err := a.catalog.ConsumeAuth(r.Context(), proof.ID, time.Now().Add(time.Minute)) |
| 114 | if err != nil { |
| 115 | http.Error(w, "authorization storage failed" , http.StatusInternalServerError) |
| 116 | return |
| 117 | } |
| 118 | if !consumed { |
| 119 | http.Error(w, "authorization has already been used" , http.StatusUnauthorized) |
| 120 | return |
| 121 | } |
| 122 | meta, err := a.Create(r.Context(), catalog.CreateOptions{Name: input.Name, Owner: input.Owner, Template: input.Template, Source: input.Source}) |
| 123 | if err != nil { |
| 124 | http.Error(w, err.Error(), 400) |
| 125 | return |
| 126 | } |
| 127 | writeJSON(w, http.StatusCreated, map[string]any{"name" : meta.Name, "owner" : meta.Owner, "status" : meta.Status, "url" : strings.TrimSuffix(a.publicURL(r, "" ), "/" ) + "/r/" + meta.Name}) |
| 128 | } |
| 129 | |
| 130 | func (a *App) provisionAllowed(pubkey string) bool { |
| 131 | for _, allowed := range a.cfg.ProvisionOwners { |
| 132 | if strings.EqualFold(strings.TrimSpace(allowed), pubkey) { |
| 133 | return true |
| 134 | } |
| 135 | } |
| 136 | return false |
| 137 | } |
| 138 | |
| 139 | func (a *App) publicRelays(ctx context.Context, r *http.Request) ([]landingRelay, error) { |
| 140 | items, err := a.catalog.List(ctx) |
| 141 | if err != nil { |
| 142 | return nil, err |
| 143 | } |
| 144 | result := make([]landingRelay, 0, len(items)) |
| 145 | base := strings.TrimSuffix(a.publicURL(r, "" ), "/" ) |
| 146 | for _, item := range items { |
| 147 | if item.Status != catalog.StatusReady { |
| 148 | continue |
| 149 | } |
| 150 | store, openErr := storage.Open(ctx, item.Paths.Database) |
| 151 | if openErr != nil { |
| 152 | continue |
| 153 | } |
| 154 | var p policy.Policy |
| 155 | policyErr := store.GetSetting(ctx, "policy" , &p) |
| 156 | _ = store.Close() |
| 157 | if policyErr != nil || !p.DirectoryPublic { |
| 158 | continue |
| 159 | } |
| 160 | result = append(result, landingRelay{Name: item.Name, URL: base + "/r/" + item.Name, Status: string(item.Status)}) |
| 161 | } |
| 162 | return result, nil |
| 163 | } |