tinyrelayfile
tinyrelay/internal/daemon/landing.go/

internal/daemon/landing.go raw
1package daemon
2
3import (
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.
23func (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
43type landingRelay struct{ Name, URL, Status string }
44
45func (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
56func (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>
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}
66
67func (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
130func (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
139func (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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs