oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "amadan.net/oficina/estilo.git/chrome"
5 "amadan.net/oficina/estilo.git/credits"
6 "amadan.net/oficina/estilo.git/menu"
7 "amadan.net/oficina/home/connect"
8 "bytes"
9 "embed"
10 "html/template"
11 "net/http"
12
13 "amadan.net/rastrillo/rastrillo"
14 "amadan.net/rastrillo/rastrillo/sessions"
15 "amadan.net/rastrillo/rastrillo/ui"
16
17 "amadan.net/oficina/calendar/internal/calendar/icons"
18)
19
20// all:, not a bare pattern. The default embed pattern SKIPS files whose
21// names begin with "_" or "." — so templates/_chip.html, the partial
22// every view shares, silently did not exist in the embedded filesystem
23// and the app panicked at init with "pattern matches no files". The
24// prefix is what makes the underscore convention safe here.
25//
26//go:embed all:templates all:static
27var appFS embed.FS
28
29// assets fingerprints static/: {{asset "static/tokens.css"}} in a
30// template returns a content-hashed URL, served immutable-cacheable by
31// the handler New mounts.
32var assets = rastrillo.NewAssets(appFS)
33
34// pageNames is every page this app renders. Kept as a list rather than
35// discovered from the filesystem so a template that is never rendered
36// is a compile-time-visible mistake rather than dead weight nobody
37// notices.
38var pageNames = []string{
39 "home", "signin", "errors", "credits", "day", "week", "month", "agenda",
40 "event", "event_form", "event_delete", "calendars", "share",
41 "import", "import_done", "settings",
42 "booking_pages", "booking_page_form",
43 "book_page", "book_sent", "book_hold", "book_manage", "book_gone",
44}
45
46// pages is one *template.Template per page, each combining layout.html
47// with that page's own file — separate trees so every page can define a
48// template named "content" without colliding with the others.
49var pages = map[string]*template.Template{}
50
51func init() {
52 for _, name := range pageNames {
53 // ui's partials and their helpers first, so every page can
54 // call one without registering anything. WithIcons points
55 // ui's two icon seams at the app's own set — both of them,
56 // always: passing only icon would leave iconAssets on the
57 // framework default and silently break cdn and js delivery.
58 t := template.Must(template.New("layout").
59 Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
60 Funcs(template.FuncMap{"asset": assets.Path}).
61 Funcs(appFuncs).
62 ParseFS(ui.Templates(), "*.html"))
63 // estilo's menu and chrome: the bar, the account menu, the error
64 // page and the sign-in door. chrome's templates call ui's field and
65 // callout partials, already in the tree, and name menu's.
66 t = template.Must(t.ParseFS(menu.Templates(), "templates/*.html"))
67 t = template.Must(t.ParseFS(chrome.Templates(), "templates/*.html"))
68 t = template.Must(t.ParseFS(appFS, "templates/layout.html", "templates/_chip.html",
69 "templates/_quickadd.html", "templates/_quickpane.html", "templates/_bookingpane.html",
70 "templates/"+name+".html"))
71 // home_link for the account pages; oauth for the Keymail
72 // continuation's meta refresh (home-oauth-head) on signin.
73 t = template.Must(t.ParseFS(connect.Templates(), "templates/home_link.html", "templates/oauth.html"))
74 pages[name] = template.Must(t.ParseFS(credits.Templates(), "templates/*.html"))
75 }
76}
77
78// fragments is the partials on their own, with no layout around them.
79//
80// It exists for the panes that are fetched and swapped into a page that
81// is already open — quick add's, and the booking form's. Rendering one
82// through pages[...] would wrap it in the whole shell — a second <html>
83// inside the one on screen.
84var fragments = template.Must(
85 template.Must(template.New("fragments").
86 Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
87 Funcs(template.FuncMap{"asset": assets.Path}).
88 Funcs(appFuncs).
89 ParseFS(ui.Templates(), "*.html")).
90 ParseFS(appFS, "templates/_quickpane.html", "templates/_bookingpane.html"))
91
92// renderFragment writes one partial, with no shell.
93func renderFragment(w http.ResponseWriter, name string, data any) {
94 var buf bytes.Buffer
95 if err := fragments.ExecuteTemplate(&buf, name, data); err != nil {
96 http.Error(w, "Something went wrong.", http.StatusInternalServerError)
97 return
98 }
99 w.Header().Set("Content-Type", "text/html; charset=utf-8")
100 buf.WriteTo(w)
101}
102
103// PageData is the shell every page renders inside. Data is NAMED
104// rather than embedded, so a template always says ".Data.Events" and
105// can never accidentally resolve a page field where it meant a shell
106// one, or the reverse.
107type PageData struct {
108 // Title is the <title>, and usually the page's own <h1>.
109 Title string
110
111 // ThemeAttr is the value of <html data-theme>, or "" for auto —
112 // which the layout renders as no attribute at all. See theme.go:
113 // this is the light/dark SCHEME, never rastrillo's theme NAME.
114 ThemeAttr string
115
116 // Mode is the stored preference behind ThemeAttr ("auto", "light",
117 // "dark"), so the switcher can mark its current choice.
118 Mode string
119
120 // Chrome is the suite-owned 52px bar. It is built in page so error
121 // pages and ordinary pages cannot accidentally grow different chrome.
122 Chrome chrome.Bar
123
124 // Path is the current request path, so a form that posts elsewhere
125 // and redirects back — the mode switcher — can name where "back" is.
126 Path string
127
128 // SignedIn and Address describe the viewer. Address is shown in the
129 // account menu and nowhere else.
130 HomeEnabled bool
131 SignedIn bool
132 Address string
133
134 // QuickPane is the quick-add box's first paint. It is on the SHELL
135 // rather than on each page's own data because the box is in the
136 // shell: a page that forgot to build one would render an empty
137 // pane and look like a broken feature rather than a missing field.
138 QuickPane QuickPaneView
139
140 Data any
141}
142
143// page builds the shell for a request. One place decides how the theme,
144// the viewer and the return path are read, so no handler can forget one
145// and render a page whose switcher silently does nothing.
146func (h *handlers) page(r *http.Request, title string, data any) PageData {
147 mode := chrome.Appearance(r)
148 pd := PageData{
149 Title: title,
150 HomeEnabled: h.homeEnabled,
151 ThemeAttr: chrome.ThemeAttr(mode),
152 Mode: string(mode),
153 Path: r.URL.Path,
154 QuickPane: emptyPane(),
155 Data: data,
156 }
157 if id, ok := h.viewer(r); ok {
158 pd.SignedIn = true
159 pd.Address = id.Address
160 }
161 pd.Chrome = calendarChrome(r, pd.SignedIn, pd.Address, mode, title, h.directory)
162 // Every door — the form, its notices, the confirm step an emailed
163 // link opens on, and the refusal auth answers /auth/* with — shows
164 // the brand alone: no account menu, no Sign in.
165 if _, door := data.(signinData); door || r.URL.Path == "/signin" || r.URL.Path == "/signin/local" || r.URL.Path == "/signed-out" {
166 pd.Chrome.Account, pd.Chrome.SignIn = nil, nil
167 }
168 return pd
169}
170
171// render executes a page into a buffer before anything touches the
172// wire: a template error becomes a clean 500 instead of garbage
173// appended to a half-written page, and headers stay settable until the
174// status line is written.
175//
176// The error string is fixed and says nothing about what failed.
177// html/template's own errors quote the data being rendered, so passing
178// one through to the browser would be a way to read a stranger's
179// calendar back out of an error page.
180func render(w http.ResponseWriter, name string, data any) {
181 var buf bytes.Buffer
182 if err := pages[name].ExecuteTemplate(&buf, "layout", data); err != nil {
183 http.Error(w, "Something went wrong.", http.StatusInternalServerError)
184 return
185 }
186 buf.WriteTo(w)
187}
188
189// renderStatus is render answering with status, written only once the
190// page has rendered, so a template error is still a clean 500.
191func renderStatus(w http.ResponseWriter, name string, status int, data any) {
192 var buf bytes.Buffer
193 if err := pages[name].ExecuteTemplate(&buf, "layout", data); err != nil {
194 http.Error(w, "Something went wrong.", http.StatusInternalServerError)
195 return
196 }
197 w.Header().Set("Content-Type", "text/html; charset=utf-8")
198 w.WriteHeader(status)
199 buf.WriteTo(w)
200}
201
202// ErrorPage renders the shared error page for status, with ref being
203// rastrillo's own request reference or empty when there is none. It is
204// the rastrillo.ErrorPageFunc cmd/calendar/main.go points
205// Options.ErrorPage at, so a panic gets the same page a handled failure
206// would.
207func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
208 id, ok := viewer(r)
209 _, session := sessions.Current(r)
210 renderError(w, r, status, ref, id.Address, ok || session, "")
211}
212
213// errorPage is ErrorPage for a request the app is serving: the viewer is
214// resolved the way every other page resolves it (h.viewer, which knows a
215// Home session's person), so the error page carries the same chrome. A
216// live session whose person cannot be resolved still gets the account
217// menu, to sign out from, with no identity in it.
218func (h *handlers) errorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
219 id, ok := h.viewer(r)
220 _, session := sessions.Current(r)
221 if !ok {
222 id.Address = ""
223 }
224 renderError(w, r, status, ref, id.Address, ok || session, h.directory)
225}
226
227func renderError(w http.ResponseWriter, r *http.Request, status int, ref, address string, signed bool, directory string) {
228 mode := chrome.Appearance(r)
229 title, errPage := errorData(r, status, ref)
230 w.WriteHeader(status)
231 render(w, "errors", PageData{
232 Title: title,
233 ThemeAttr: chrome.ThemeAttr(mode),
234 Mode: string(mode),
235 Path: r.URL.Path,
236 SignedIn: signed,
237 Address: address,
238 Chrome: calendarChrome(r, signed, address, mode, title, directory),
239 Data: errPage,
240 })
241}
242
243// errorData is the shared-chrome spec's §3.2 table: the tab title, then the
244// page's h1, body and primary way back. A 404 never says whether the thing
245// exists (not shared and deleted read the same). A 500 or 503 on a GET
246// offers the same URL again; any other method goes to Calendar, because
247// re-sending a POST from a link is not possible and not wanted.
248func errorData(r *http.Request, status int, ref string) (string, chrome.ErrorPage) {
249 home := chrome.Link{Href: "/", Label: "Go to Calendar"}
250 again := home
251 if r.Method == http.MethodGet {
252 again = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"}
253 }
254 title, h1, body, cta := "Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below.", again
255 switch status {
256 case http.StatusNotFound:
257 title, h1, body, cta = "Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved.", home
258 case http.StatusForbidden:
259 title, h1, body, cta = "No access", "You don’t have access to this page", "Ask whoever runs this team if you think you should.", home
260 case http.StatusServiceUnavailable:
261 title, h1, body, cta = "Calendar is unavailable", "Calendar is unavailable right now", "Try again in a few minutes.", again
262 case http.StatusBadRequest:
263 // Not in the table: a malformed request is the person's link or
264 // form, not our failure, so it reads as the 404's way back.
265 title, h1, body, cta = "Something went wrong", "That request didn’t work", "The link or form may be out of date. Go back and try again.", home
266 }
267 return title, chrome.ErrorPage{Status: status, Title: h1, Body: body, Home: cta, Ref: ref, RefLabel: "Reference:"}
268}
269
270// notFound has an HTML page only for navigation. API/feed clients retain the
271// ordinary compact 404, which is both the useful representation and prevents
272// an HTML error page from being mistaken for a successful feed response.
273func (h *handlers) notFound(w http.ResponseWriter, r *http.Request) {
274 if !containsHTML(r.Header.Get("Accept")) {
275 http.NotFound(w, r)
276 return
277 }
278 w.WriteHeader(http.StatusNotFound)
279 title, page := errorData(r, http.StatusNotFound, "")
280 render(w, "errors", h.page(r, title, page))
281}
282
283func containsHTML(accept string) bool {
284 for i := 0; i+9 <= len(accept); i++ {
285 if accept[i:i+9] == "text/html" {
286 return true
287 }
288 }
289 return false
290}
291