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(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}