clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / internal / daemon / landing.go
landing.go 8.4K
1package daemon23import (4 "context"5 "encoding/json"6 "html/template"7 "io"8 "net/http"9 "net/url"10 "strings"11 "time"1213 "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)1920// ServeLanding handles only the process landing page and permanent relay21// creation. It returns false for tenant routes so App.ServeHTTP can continue22// with normal host and /r/<name> resolution.23func (a *App) ServeLanding(w http.ResponseWriter, r *http.Request) bool {24 if r.URL.Path != "/" && r.URL.Path != "/relays" {25 return false26 }27 if r.Method == http.MethodGet && r.URL.Path == "/" {28 a.landingPage(w, r)29 return true30 }31 if r.Method == http.MethodGet && r.URL.Path == "/relays" {32 a.relayList(w, r)33 return true34 }35 if r.Method == http.MethodPost && r.URL.Path == "/relays" {36 a.createRelay(w, r)37 return true38 }39 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)40 return true41}4243type landingRelay struct{ Name, URL, Status string }4445func (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 return50 }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}5556func (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 return60 }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>62const 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}6667func (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 return76 }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 return96 }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 return104 }105 if proof.PubKey != input.Owner {106 http.Error(w, "owner must match the signer", 403)107 return108 }109 if !a.provisionAllowed(proof.PubKey) {110 http.Error(w, "browser provisioning is disabled for this owner", http.StatusForbidden)111 return112 }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 return117 }118 if !consumed {119 http.Error(w, "authorization has already been used", http.StatusUnauthorized)120 return121 }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 return126 }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}129130func (a *App) provisionAllowed(pubkey string) bool {131 for _, allowed := range a.cfg.ProvisionOwners {132 if strings.EqualFold(strings.TrimSpace(allowed), pubkey) {133 return true134 }135 }136 return false137}138139func (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, err143 }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 continue149 }150 store, openErr := storage.Open(ctx, item.Paths.Database)151 if openErr != nil {152 continue153 }154 var p policy.Policy155 policyErr := store.GetSetting(ctx, "policy", &p)156 _ = store.Close()157 if policyErr != nil || !p.DirectoryPublic {158 continue159 }160 result = append(result, landingRelay{Name: item.Name, URL: base + "/r/" + item.Name, Status: string(item.Status)})161 }162 return result, nil163}