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(fs, "static/dashboard.html.tmpl", "static/head.html.tmpl", "static/header.html.tmpl", "static/footer.html.tmpl"))
70 if err := tmpl.Execute(w, data); err != nil {
71 fmt.Println(err)
72 }
73}
74
75func (h Handler) NewHandler(w http.ResponseWriter, r *http.Request) {
76 if !h.isAuthorised(r) {
77 http.Redirect(w, r, "/login", http.StatusSeeOther)
78 return
79 }
80
81 params := r.URL.Query()
82
83 action := bmStrict.Sanitize(params.Get("action"))
84 if r.Method == http.MethodGet {
85 if action == "" {
86 data := struct{ Version string }{Version: *h.Version}
87
88 tmpl := template.Must(template.ParseFS(fs, "static/new.html.tmpl", "static/head.html.tmpl", "static/header.html.tmpl", "static/footer.html.tmpl"))
89 if err := tmpl.Execute(w, data); err != nil {
90 fmt.Println(err)
91 }
92 } else if action != "delete" {
93 submittedURL := bmStrict.Sanitize(params.Get("url"))
94 if submittedURL == "" {
95 w.WriteHeader(http.StatusBadRequest)
96
97 _, err := w.Write([]byte("No URL provided"))
98 if err != nil {
99 fmt.Println(err)
100 }
101
102 return
103 }
104
105 forge := bmStrict.Sanitize(params.Get("forge"))
106 if forge == "" {
107 w.WriteHeader(http.StatusBadRequest)
108
109 _, err := w.Write([]byte("No forge provided"))
110 if err != nil {
111 fmt.Println(err)
112 }
113
114 return
115 }
116
117 name := bmStrict.Sanitize(params.Get("name"))
118 if name == "" {
119 w.WriteHeader(http.StatusBadRequest)
120
121 _, err := w.Write([]byte("No name provided"))
122 if err != nil {
123 fmt.Println(err)
124 }
125
126 return
127 }
128
129 proj := project.Project{
130 ID: project.GenProjectID(submittedURL, name, forge),
131 URL: submittedURL,
132 Name: name,
133 Forge: forge,
134 }
135
136 proj, err := project.GetProject(h.DbConn, proj)
137 if err != nil && !errors.Is(err, sql.ErrNoRows) {
138 w.WriteHeader(http.StatusBadRequest)
139
140 _, err := fmt.Fprintf(w, "Error getting project: %s", err)
141 if err != nil {
142 fmt.Println(err)
143 }
144
145 return
146 }
147
148 proj, err = project.GetReleases(h.DbConn, h.Mu, proj)
149 if err != nil {
150 w.WriteHeader(http.StatusBadRequest)
151
152 _, err := fmt.Fprintf(w, "Error getting releases: %s", err)
153 if err != nil {
154 fmt.Println(err)
155 }
156
157 return
158 }
159
160 data := struct {
161 Version string
162 Project project.Project
163 }{
164 Version: *h.Version,
165 Project: proj,
166 }
167
168 tmpl := template.Must(template.ParseFS(fs, "static/select-release.html.tmpl", "static/head.html.tmpl", "static/header.html.tmpl", "static/footer.html.tmpl"))
169 if err := tmpl.Execute(w, data); err != nil {
170 fmt.Println(err)
171 }
172 } else if action == "delete" {
173 submittedID := params.Get("id")
174 if submittedID == "" {
175 w.WriteHeader(http.StatusBadRequest)
176
177 _, err := w.Write([]byte("No URL provided"))
178 if err != nil {
179 fmt.Println(err)
180 }
181
182 return
183 }
184
185 project.Untrack(h.DbConn, h.Mu, submittedID)
186 http.Redirect(w, r, "/", http.StatusSeeOther)
187 }
188 }
189
190 if r.Method == http.MethodPost {
191 err := r.ParseForm()
192 if err != nil {
193 fmt.Println(err)
194 }
195
196 idValue := bmStrict.Sanitize(r.FormValue("id"))
197 nameValue := bmStrict.Sanitize(r.FormValue("name"))
198 urlValue := bmStrict.Sanitize(r.FormValue("url"))
199 forgeValue := bmStrict.Sanitize(r.FormValue("forge"))
200 releaseValue := bmStrict.Sanitize(r.FormValue("release"))
201
202 // If releaseValue is not empty, we're updating an existing project
203 if idValue != "" && nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue != "" {
204 project.Track(h.DbConn, h.Mu, h.ManualRefresh, nameValue, urlValue, forgeValue, releaseValue)
205 http.Redirect(w, r, "/", http.StatusSeeOther)
206
207 return
208 }
209
210 // If releaseValue is empty, we're creating a new project
211 if idValue == "" && nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue == "" {
212 http.Redirect(w, r, "/new?action=yoink&name="+url.QueryEscape(nameValue)+"&url="+url.QueryEscape(urlValue)+"&forge="+url.QueryEscape(forgeValue), http.StatusSeeOther)
213 return
214 }
215
216 w.WriteHeader(http.StatusBadRequest)
217
218 _, err = w.Write([]byte("No data provided"))
219 if err != nil {
220 fmt.Println(err)
221 }
222 }
223}
224
225func (h Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
226 if r.Method == http.MethodGet {
227 if h.isAuthorised(r) {
228 http.Redirect(w, r, "/", http.StatusSeeOther)
229 return
230 }
231
232 data := struct {
233 Version string
234 }{
235 Version: *h.Version,
236 }
237
238 tmpl := template.Must(template.ParseFS(fs, "static/login.html.tmpl", "static/head.html.tmpl", "static/footer.html.tmpl"))
239 if err := tmpl.Execute(w, data); err != nil {
240 fmt.Println(err)
241 }
242 }
243
244 if r.Method == http.MethodPost {
245 err := r.ParseForm()
246 if err != nil {
247 fmt.Println(err)
248 }
249
250 username := bmStrict.Sanitize(r.FormValue("username"))
251 password := bmStrict.Sanitize(r.FormValue("password"))
252
253 if username == "" || password == "" {
254 w.WriteHeader(http.StatusBadRequest)
255
256 _, err := w.Write([]byte("No data provided"))
257 if err != nil {
258 fmt.Println(err)
259 }
260
261 return
262 }
263
264 authorised, err := users.UserAuthorised(h.DbConn, username, password)
265 if err != nil {
266 w.WriteHeader(http.StatusBadRequest)
267
268 _, err := fmt.Fprintf(w, "Error logging in: %s", err)
269 if err != nil {
270 fmt.Println(err)
271 }
272
273 return
274 }
275
276 if !authorised {
277 w.WriteHeader(http.StatusUnauthorized)
278
279 _, err := w.Write([]byte("Incorrect username or password"))
280 if err != nil {
281 fmt.Println(err)
282 }
283
284 return
285 }
286
287 session, expiry, err := users.CreateSession(h.DbConn, username)
288 if err != nil {
289 w.WriteHeader(http.StatusBadRequest)
290
291 _, err := fmt.Fprintf(w, "Error creating session: %s", err)
292 if err != nil {
293 fmt.Println(err)
294 }
295
296 return
297 }
298
299 maxAge := int(time.Until(expiry))
300
301 cookie := http.Cookie{
302 Name: "id",
303 Value: session,
304 MaxAge: maxAge,
305 HttpOnly: true,
306 SameSite: http.SameSiteStrictMode,
307 Secure: true,
308 }
309
310 http.SetCookie(w, &cookie)
311 http.Redirect(w, r, "/", http.StatusSeeOther)
312 }
313}
314
315func (h Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
316 cookie, err := r.Cookie("id")
317 if err != nil {
318 fmt.Println(err)
319 }
320
321 err = users.InvalidateSession(h.DbConn, cookie.Value)
322 if err != nil {
323 fmt.Println(err)
324
325 _, err = fmt.Fprintf(w, "Error logging out: %s", err)
326 if err != nil {
327 fmt.Println(err)
328 }
329
330 return
331 }
332
333 cookie.MaxAge = -1
334 http.SetCookie(w, cookie)
335 http.Redirect(w, r, "/login", http.StatusSeeOther)
336}
337
338// isAuthorised makes a database request to the sessions table to see if the
339// user has a valid session cookie.
340func (h Handler) isAuthorised(r *http.Request) bool {
341 cookie, err := r.Cookie("id")
342 if err != nil {
343 return false
344 }
345
346 authorised, err := users.SessionAuthorised(h.DbConn, cookie.Value)
347 if err != nil {
348 fmt.Println("Error checking session:", err)
349 return false
350 }
351
352 return authorised
353}
354
355func StaticHandler(writer http.ResponseWriter, request *http.Request) {
356 resource := strings.TrimPrefix(request.URL.Path, "/")
357 if strings.HasSuffix(resource, ".css") {
358 writer.Header().Set("Content-Type", "text/css")
359 } else if strings.HasSuffix(resource, ".js") {
360 writer.Header().Set("Content-Type", "text/javascript")
361 }
362
363 home, err := fs.ReadFile(resource)
364 if err != nil {
365 fmt.Println(err)
366 }
367
368 if _, err = io.Writer.Write(writer, home); err != nil {
369 fmt.Println(err)
370 }
371}