carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The app Settings tab's domain attach/detach door (#82, Task 4): thin
2// browser-form handlers over the attachDomain/detachDomain core in
3// domainattach.go. Everything that decides whether an attach or detach is
4// ALLOWED — platform space, alias-vs-first-route, the claim/record write
5// order, the audit row, the serial bump — lives there and is not repeated
6// here. What's here is the browser's own shape: session, CSRF, the
7// owner-only gate, and turning the core's answer into a page.
8//
9// Gate order copies handleAppDelete's (appsettings.go) exactly, minus
10// requireFreshAuth: session (accountApp) -> CSRF -> owner (byte-identical
11// 404) -> refusals -> act. Spec §6: attach/detach are member-reversible, no
12// passkey step-up — detach's protection is the typed-host confirm below,
13// not a passkey.
14package console
15
16import (
17 "context"
18 "crypto/subtle"
19 "log"
20 "net/http"
21 "strings"
22 "time"
23
24 "github.com/carlosframework/platform/internal/domains"
25)
26
27// handleAppDomainAttach claims a host for the app and, when the app has no
28// route to alias, declares the static route that becomes its first one.
29// The browser door never sends a kind — the core infers alias vs
30// first-route from whether the app already has a ready route — and the
31// channel field is optional, passed through as typed.
32func (s *Server) handleAppDomainAttach(w http.ResponseWriter, r *http.Request) {
33 addr, raw, acct, role, app, ok := s.accountApp(w, r)
34 if !ok {
35 return
36 }
37 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
38 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
39 return
40 }
41 if role != RoleOwner {
42 http.NotFound(w, r)
43 return
44 }
45 host := strings.ToLower(strings.TrimSpace(r.FormValue("host")))
46 channel := strings.TrimSpace(r.FormValue("channel"))
47
48 ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
49 defer cancel()
50 // "*.<parent>" is an instance catch-all (domaincatchall.go). The owner
51 // gate above is the person check: a session is always a person.
52 if parent, ok := domains.ParseInstanceCatchallHost(host); ok {
53 if channel != "" {
54 s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "domains",
55 "an instance catch-all takes no channel — each instance keeps its own", map[string]string{"domain": host})
56 return
57 }
58 ref := s.catchallAppReady(ctx, acct, app)
59 if ref == nil {
60 _, ref = s.attachInstanceCatchall(ctx, acct, app, parent, addr, "")
61 }
62 if ref != nil {
63 s.renderDoorRefusal(w, r, ref)
64 return
65 }
66 s.setFlash(w, "ok", "Attached "+domains.InstanceCatchallHost(parent)+" — each instance now also answers at <label>."+parent+".")
67 http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"), http.StatusSeeOther)
68 return
69 }
70 res, ref := s.attachDomain(ctx, acct, app, host, "", channel, addr)
71 if ref != nil {
72 if ref.Status == http.StatusUnprocessableEntity {
73 s.renderAppSettingsForm(w, r, ref.Status, "domains", ref.Msg, map[string]string{
74 "domain": host, "channel": channel,
75 })
76 return
77 }
78 s.renderDoorRefusal(w, r, ref)
79 return
80 }
81 if res.OperationID != "" {
82 s.renderClaimAccepted(w, r, host, res.OperationID, res.OperationPhase)
83 return
84 }
85 msg := "Attached " + host + " — it now aliases the app's existing route."
86 if res.FirstRoute {
87 msg = "Attached " + host + " as the app's first route, following " + res.Channel + "."
88 }
89 if res.Pending {
90 // #215: stored, not served. The flash carries the exact record, so
91 // the member never has to go looking for what to do next.
92 msg = "Saved " + host + ", but it is not live yet. " + verifyGuidance(res.Verify)
93 }
94 s.setFlash(w, "ok", msg)
95 http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"), http.StatusSeeOther)
96}
97
98// handleAppDomainDetach releases a host's claim. The typed-host confirm is
99// the browser door's own gate — spec §6's substitute for a passkey step-up
100// on a reversible action — and is checked BEFORE the core is called: a
101// mismatch must leave the claim exactly as it was.
102func (s *Server) handleAppDomainDetach(w http.ResponseWriter, r *http.Request) {
103 addr, raw, acct, role, app, ok := s.accountApp(w, r)
104 if !ok {
105 return
106 }
107 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
108 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
109 return
110 }
111 if role != RoleOwner {
112 http.NotFound(w, r)
113 return
114 }
115 host := strings.ToLower(strings.TrimSpace(r.FormValue("host")))
116 if strings.ToLower(strings.TrimSpace(r.FormValue("confirm"))) != host {
117 s.renderMessage(w, r, http.StatusUnprocessableEntity, "Refused", "Type the domain exactly to detach it.")
118 return
119 }
120
121 ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
122 defer cancel()
123 if parent, ok := domains.ParseInstanceCatchallHost(host); ok {
124 if _, err := s.detachInstanceCatchall(ctx, acct, app, parent, addr); err != nil {
125 if msg, ok := claimAliasRefusal(err); ok {
126 s.renderMessage(w, r, http.StatusConflict, "Refused", msg)
127 return
128 }
129 log.Printf("console: detach %s/%s %s: %v", acct.Sqid, app, host, err)
130 s.renderMessage(w, r, http.StatusBadGateway, "Refused", "Could not detach the catch-all — try again.")
131 return
132 }
133 s.setFlash(w, "ok", "Detached "+host+".")
134 http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"), http.StatusSeeOther)
135 return
136 }
137 if ref := s.detachDomain(ctx, acct, app, host, addr); ref != nil {
138 s.renderDoorRefusal(w, r, ref)
139 return
140 }
141 s.setFlash(w, "ok", "Detached "+host+".")
142 http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"), http.StatusSeeOther)
143}
144
145// renderDoorRefusal turns a doorRefusal from the Task 3 core into this
146// door's page. 404 MUST be http.NotFound: the stranger's byte-identical
147// 404, not a rendered message that would tell a prober "an app exists
148// here, but this host does not" versus "nothing exists here at all".
149// Every other status renders through renderMessage with the refusal's own
150// status and member-facing text.
151func (s *Server) renderDoorRefusal(w http.ResponseWriter, r *http.Request, ref *doorRefusal) {
152 if ref.Status == http.StatusNotFound {
153 http.NotFound(w, r)
154 return
155 }
156 setNoCapacityRetryAfter(w, ref)
157 s.renderMessage(w, r, ref.Status, "Refused", ref.Msg)
158}
159