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