1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5package ws
6
7import (
8 "database/sql"
9 "embed"
10 "errors"
11 "fmt"
12 "io"
13 "net/http"
14 "net/url"
15 "strings"
16 "sync"
17 "text/template"
18 "time"
19
20 "git.sr.ht/~amolith/willow/project"
21 "git.sr.ht/~amolith/willow/users"
22 "github.com/microcosm-cc/bluemonday"
23)
24
25type Handler struct {
26 DbConn *sql.DB
27 Req *chan struct{}
28 ManualRefresh *chan struct{}
29 Res *chan []project.Project
30 Mu *sync.Mutex
31 Version *string
32}
33
34//go:embed static
35var fs embed.FS
36
37// bmUGC = bluemonday.UGCPolicy().
38var bmStrict = bluemonday.StrictPolicy()
39
40func (h Handler) RootHandler(w http.ResponseWriter, r *http.Request) {
41 if !h.isAuthorised(r) {
42 http.Redirect(w, r, "/login", http.StatusSeeOther)
43 return
44 }
45
46 projectsWithReleases, err := project.GetProjectsWithReleases(h.DbConn, h.Mu)
47 if err != nil {
48 fmt.Println(err)
49 w.WriteHeader(http.StatusInternalServerError)
50
51 _, err := w.Write([]byte("Internal Server Error"))
52 if err != nil {
53 fmt.Println(err)
54 }
55
56 return
57 }
58
59 data := struct {
60 Version string
61 Projects []project.Project
62 IsDashboard bool
63 }{
64 Version: *h.Version,
65 Projects: projectsWithReleases,
66 IsDashboard: true,
67 }
68
69 tmpl := template.Must(template.ParseFS(
70 fs,
71 "static/dashboard.html.tmpl",
72 "static/head.html.tmpl",
73 "static/header.html.tmpl",
74 "static/footer.html.tmpl",
75 ))
76 if err := tmpl.Execute(w, data); err != nil {
77 fmt.Println(err)
78 }
79}
80
81func (h Handler) NewHandler(w http.ResponseWriter, r *http.Request) {
82 if !h.isAuthorised(r) {
83 http.Redirect(w, r, "/login", http.StatusSeeOther)
84 return
85 }
86
87 params := r.URL.Query()
88
89 action := bmStrict.Sanitize(params.Get("action"))
90 if r.Method == http.MethodGet {
91 if action == "" {
92 data := struct{ Version string }{Version: *h.Version}
93
94 tmpl := template.Must(template.ParseFS(
95 fs,
96 "static/new.html.tmpl",
97 "static/head.html.tmpl",
98 "static/header.html.tmpl",
99 "static/footer.html.tmpl",
100 ))
101 if err := tmpl.Execute(w, data); err != nil {
102 fmt.Println(err)
103 }
104 } else if action != "delete" {
105 submittedURL := bmStrict.Sanitize(params.Get("url"))
106 if submittedURL == "" {
107 w.WriteHeader(http.StatusBadRequest)
108
109 _, err := w.Write([]byte("No URL provided"))
110 if err != nil {
111 fmt.Println(err)
112 }
113
114 return
115 }
116
117 forge := bmStrict.Sanitize(params.Get("forge"))
118 if forge == "" {
119 w.WriteHeader(http.StatusBadRequest)
120
121 _, err := w.Write([]byte("No forge provided"))
122 if err != nil {
123 fmt.Println(err)
124 }
125
126 return
127 }
128
129 name := bmStrict.Sanitize(params.Get("name"))
130 if name == "" {
131 w.WriteHeader(http.StatusBadRequest)
132
133 _, err := w.Write([]byte("No name provided"))
134 if err != nil {
135 fmt.Println(err)
136 }
137
138 return
139 }
140
141 proj := project.Project{
142 ID: project.GenProjectID(submittedURL, name, forge),
143 URL: submittedURL,
144 Name: name,
145 Forge: forge,
146 Running: "",
147 Releases: nil,
148 }
149
150 proj, err := project.GetProject(h.DbConn, proj)
151 if err != nil && !errors.Is(err, sql.ErrNoRows) {
152 w.WriteHeader(http.StatusBadRequest)
153
154 _, err := fmt.Fprintf(w, "Error getting project: %s", err)
155 if err != nil {
156 fmt.Println(err)
157 }
158
159 return
160 }
161
162 proj, err = project.GetReleases(h.DbConn, h.Mu, proj)
163 if err != nil {
164 w.WriteHeader(http.StatusBadRequest)
165
166 _, err := fmt.Fprintf(w, "Error getting releases: %s", err)
167 if err != nil {
168 fmt.Println(err)
169 }
170
171 return
172 }
173
174 data := struct {
175 Version string
176 Project project.Project
177 }{
178 Version: *h.Version,
179 Project: proj,
180 }
181
182 tmpl := template.Must(template.ParseFS(
183 fs,
184 "static/select-release.html.tmpl",
185 "static/head.html.tmpl",
186 "static/header.html.tmpl",
187 "static/footer.html.tmpl",
188 ))
189 if err := tmpl.Execute(w, data); err != nil {
190 fmt.Println(err)
191 }
192 } else if action == "delete" {
193 submittedID := params.Get("id")
194 if submittedID == "" {
195 w.WriteHeader(http.StatusBadRequest)
196
197 _, err := w.Write([]byte("No URL provided"))
198 if err != nil {
199 fmt.Println(err)
200 }
201
202 return
203 }
204
205 project.Untrack(h.DbConn, h.Mu, submittedID)
206 http.Redirect(w, r, "/", http.StatusSeeOther)
207 }
208 }
209
210 if r.Method == http.MethodPost {
211 err := r.ParseForm()
212 if err != nil {
213 fmt.Println(err)
214 }
215
216 idValue := bmStrict.Sanitize(r.FormValue("id"))
217 nameValue := bmStrict.Sanitize(r.FormValue("name"))
218 urlValue := bmStrict.Sanitize(r.FormValue("url"))
219 forgeValue := bmStrict.Sanitize(r.FormValue("forge"))
220 releaseValue := bmStrict.Sanitize(r.FormValue("release"))
221
222 // If releaseValue is not empty, we're updating an existing project
223 if idValue != "" && nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue != "" {
224 project.Track(h.DbConn, h.Mu, h.ManualRefresh, nameValue, urlValue, forgeValue, releaseValue)
225 http.Redirect(w, r, "/", http.StatusSeeOther)
226
227 return
228 }
229
230 // If releaseValue is empty, we're creating a new project
231 if idValue == "" && nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue == "" {
232 http.Redirect(
233 w,
234 r,
235 "/new?action=yoink&name="+url.QueryEscape(nameValue)+
236 "&url="+url.QueryEscape(urlValue)+
237 "&forge="+url.QueryEscape(forgeValue),
238 http.StatusSeeOther,
239 )
240
241 return
242 }
243
244 w.WriteHeader(http.StatusBadRequest)
245
246 _, err = w.Write([]byte("No data provided"))
247 if err != nil {
248 fmt.Println(err)
249 }
250 }
251}
252
253func (h Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
254 if r.Method == http.MethodGet {
255 if h.isAuthorised(r) {
256 http.Redirect(w, r, "/", http.StatusSeeOther)
257 return
258 }
259
260 data := struct {
261 Version string
262 }{
263 Version: *h.Version,
264 }
265
266 tmpl := template.Must(template.ParseFS(
267 fs,
268 "static/login.html.tmpl",
269 "static/head.html.tmpl",
270 "static/footer.html.tmpl",
271 ))
272 if err := tmpl.Execute(w, data); err != nil {
273 fmt.Println(err)
274 }
275 }
276
277 if r.Method == http.MethodPost {
278 err := r.ParseForm()
279 if err != nil {
280 fmt.Println(err)
281 }
282
283 username := bmStrict.Sanitize(r.FormValue("username"))
284 password := bmStrict.Sanitize(r.FormValue("password"))
285
286 if username == "" || password == "" {
287 w.WriteHeader(http.StatusBadRequest)
288
289 _, err := w.Write([]byte("No data provided"))
290 if err != nil {
291 fmt.Println(err)
292 }
293
294 return
295 }
296
297 authorised, err := users.UserAuthorised(h.DbConn, username, password)
298 if err != nil {
299 w.WriteHeader(http.StatusBadRequest)
300
301 _, err := fmt.Fprintf(w, "Error logging in: %s", err)
302 if err != nil {
303 fmt.Println(err)
304 }
305
306 return
307 }
308
309 if !authorised {
310 w.WriteHeader(http.StatusUnauthorized)
311
312 _, err := w.Write([]byte("Incorrect username or password"))
313 if err != nil {
314 fmt.Println(err)
315 }
316
317 return
318 }
319
320 session, expiry, err := users.CreateSession(h.DbConn, username)
321 if err != nil {
322 w.WriteHeader(http.StatusBadRequest)
323
324 _, err := fmt.Fprintf(w, "Error creating session: %s", err)
325 if err != nil {
326 fmt.Println(err)
327 }
328
329 return
330 }
331
332 maxAge := int(time.Until(expiry))
333
334 cookie := http.Cookie{
335 Name: "id",
336 Value: session,
337 Path: "",
338 Domain: "",
339 Expires: time.Time{},
340 RawExpires: "",
341 MaxAge: maxAge,
342 Secure: true,
343 HttpOnly: true,
344 SameSite: http.SameSiteStrictMode,
345 Raw: "",
346 Unparsed: nil,
347 }
348
349 http.SetCookie(w, &cookie)
350 http.Redirect(w, r, "/", http.StatusSeeOther)
351 }
352}
353
354func (h Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
355 cookie, err := r.Cookie("id")
356 if err != nil {
357 fmt.Println(err)
358 }
359
360 err = users.InvalidateSession(h.DbConn, cookie.Value)
361 if err != nil {
362 fmt.Println(err)
363
364 _, err = fmt.Fprintf(w, "Error logging out: %s", err)
365 if err != nil {
366 fmt.Println(err)
367 }
368
369 return
370 }
371
372 cookie.MaxAge = -1
373 http.SetCookie(w, cookie)
374 http.Redirect(w, r, "/login", http.StatusSeeOther)
375}
376
377// isAuthorised makes a database request to the sessions table to see if the
378// user has a valid session cookie.
379func (h Handler) isAuthorised(r *http.Request) bool {
380 cookie, err := r.Cookie("id")
381 if err != nil {
382 return false
383 }
384
385 authorised, err := users.SessionAuthorised(h.DbConn, cookie.Value)
386 if err != nil {
387 fmt.Println("Error checking session:", err)
388 return false
389 }
390
391 return authorised
392}
393
394func StaticHandler(writer http.ResponseWriter, request *http.Request) {
395 resource := strings.TrimPrefix(request.URL.Path, "/")
396 if strings.HasSuffix(resource, ".css") {
397 writer.Header().Set("Content-Type", "text/css")
398 } else if strings.HasSuffix(resource, ".js") {
399 writer.Header().Set("Content-Type", "text/javascript")
400 }
401
402 home, err := fs.ReadFile(resource)
403 if err != nil {
404 fmt.Println(err)
405 }
406
407 if _, err = io.Writer.Write(writer, home); err != nil {
408 fmt.Println(err)
409 }
410}