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