ws.go

  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}