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	"fmt"
 11	"io"
 12	"net/http"
 13	"net/url"
 14	"strings"
 15	"sync"
 16	"text/template"
 17	"time"
 18
 19	"git.sr.ht/~amolith/willow/project"
 20	"git.sr.ht/~amolith/willow/users"
 21	"github.com/microcosm-cc/bluemonday"
 22)
 23
 24type Handler struct {
 25	DbConn        *sql.DB
 26	Req           *chan struct{}
 27	ManualRefresh *chan struct{}
 28	Res           *chan []project.Project
 29	Mu            *sync.Mutex
 30	Version       *string
 31}
 32
 33//go:embed static
 34var fs embed.FS
 35
 36// bmUGC    = bluemonday.UGCPolicy()
 37var bmStrict = bluemonday.StrictPolicy()
 38
 39func (h Handler) RootHandler(w http.ResponseWriter, r *http.Request) {
 40	if !h.isAuthorised(r) {
 41		http.Redirect(w, r, "/login", http.StatusSeeOther)
 42		return
 43	}
 44	projectsWithReleases, err := project.GetProjectsWithReleases(h.DbConn, h.Mu)
 45	if err != nil {
 46		fmt.Println(err)
 47		w.WriteHeader(http.StatusInternalServerError)
 48		_, err := w.Write([]byte("Internal Server Error"))
 49		if err != nil {
 50			fmt.Println(err)
 51		}
 52		return
 53	}
 54
 55	data := struct {
 56		Version       string
 57		Projects      []project.Project
 58		IsDashboard   bool
 59	}{
 60		Version:      *h.Version,
 61		Projects:     projectsWithReleases,
 62		IsDashboard:  true,
 63	}
 64
 65	tmpl := template.Must(template.ParseFS(fs, "static/dashboard.html.tmpl", "static/head.html.tmpl", "static/header.html.tmpl", "static/footer.html.tmpl"))
 66	if err := tmpl.Execute(w, data); err != nil {
 67		fmt.Println(err)
 68	}
 69}
 70
 71func (h Handler) NewHandler(w http.ResponseWriter, r *http.Request) {
 72	if !h.isAuthorised(r) {
 73		http.Redirect(w, r, "/login", http.StatusSeeOther)
 74		return
 75	}
 76	params := r.URL.Query()
 77	action := bmStrict.Sanitize(params.Get("action"))
 78	if r.Method == http.MethodGet {
 79		if action == "" {
 80			data := struct{ Version string }{Version: *h.Version}
 81			tmpl := template.Must(template.ParseFS(fs, "static/new.html.tmpl", "static/head.html.tmpl", "static/header.html.tmpl", "static/footer.html.tmpl"))
 82			if err := tmpl.Execute(w, data); err != nil {
 83				fmt.Println(err)
 84			}
 85		} else if action != "delete" {
 86			submittedURL := bmStrict.Sanitize(params.Get("url"))
 87			if submittedURL == "" {
 88				w.WriteHeader(http.StatusBadRequest)
 89				_, err := w.Write([]byte("No URL provided"))
 90				if err != nil {
 91					fmt.Println(err)
 92				}
 93				return
 94			}
 95
 96			forge := bmStrict.Sanitize(params.Get("forge"))
 97			if forge == "" {
 98				w.WriteHeader(http.StatusBadRequest)
 99				_, err := w.Write([]byte("No forge provided"))
100				if err != nil {
101					fmt.Println(err)
102				}
103				return
104			}
105
106			name := bmStrict.Sanitize(params.Get("name"))
107			if name == "" {
108				w.WriteHeader(http.StatusBadRequest)
109				_, err := w.Write([]byte("No name provided"))
110				if err != nil {
111					fmt.Println(err)
112				}
113				return
114			}
115
116			proj := project.Project{
117				ID:    project.GenProjectID(submittedURL, name, forge),
118				URL:   submittedURL,
119				Name:  name,
120				Forge: forge,
121			}
122
123			proj, err := project.GetProject(h.DbConn, proj)
124			if err != nil && err != sql.ErrNoRows {
125				w.WriteHeader(http.StatusBadRequest)
126				_, err := w.Write([]byte(fmt.Sprintf("Error getting project: %s", err)))
127				if err != nil {
128					fmt.Println(err)
129				}
130				return
131			}
132
133			proj, err = project.GetReleases(h.DbConn, h.Mu, proj)
134			if err != nil {
135				w.WriteHeader(http.StatusBadRequest)
136				_, err := w.Write([]byte(fmt.Sprintf("Error getting releases: %s", err)))
137				if err != nil {
138					fmt.Println(err)
139				}
140				return
141			}
142
143			data := struct {
144				Version string
145				Project project.Project
146			}{
147				Version: *h.Version,
148				Project: proj,
149			}
150
151			tmpl := template.Must(template.ParseFS(fs, "static/select-release.html.tmpl", "static/head.html.tmpl", "static/header.html.tmpl", "static/footer.html.tmpl"))
152			if err := tmpl.Execute(w, data); err != nil {
153				fmt.Println(err)
154			}
155		} else if action == "delete" {
156			submittedID := params.Get("id")
157			if submittedID == "" {
158				w.WriteHeader(http.StatusBadRequest)
159				_, err := w.Write([]byte("No URL provided"))
160				if err != nil {
161					fmt.Println(err)
162				}
163				return
164			}
165
166			project.Untrack(h.DbConn, h.Mu, submittedID)
167			http.Redirect(w, r, "/", http.StatusSeeOther)
168		}
169	}
170
171	if r.Method == http.MethodPost {
172		err := r.ParseForm()
173		if err != nil {
174			fmt.Println(err)
175		}
176		idValue := bmStrict.Sanitize(r.FormValue("id"))
177		nameValue := bmStrict.Sanitize(r.FormValue("name"))
178		urlValue := bmStrict.Sanitize(r.FormValue("url"))
179		forgeValue := bmStrict.Sanitize(r.FormValue("forge"))
180		releaseValue := bmStrict.Sanitize(r.FormValue("release"))
181
182		// If releaseValue is not empty, we're updating an existing project
183		if idValue != "" && nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue != "" {
184			project.Track(h.DbConn, h.Mu, h.ManualRefresh, nameValue, urlValue, forgeValue, releaseValue)
185			http.Redirect(w, r, "/", http.StatusSeeOther)
186			return
187		}
188
189		// If releaseValue is empty, we're creating a new project
190		if idValue == "" && nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue == "" {
191			http.Redirect(w, r, "/new?action=yoink&name="+url.QueryEscape(nameValue)+"&url="+url.QueryEscape(urlValue)+"&forge="+url.QueryEscape(forgeValue), http.StatusSeeOther)
192			return
193		}
194
195		w.WriteHeader(http.StatusBadRequest)
196		_, err = w.Write([]byte("No data provided"))
197		if err != nil {
198			fmt.Println(err)
199		}
200	}
201}
202
203func (h Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
204	if r.Method == http.MethodGet {
205		if h.isAuthorised(r) {
206			http.Redirect(w, r, "/", http.StatusSeeOther)
207			return
208		}
209
210		data := struct {
211			Version string
212		}{
213			Version: *h.Version,
214		}
215		tmpl := template.Must(template.ParseFS(fs, "static/login.html.tmpl", "static/head.html.tmpl", "static/footer.html.tmpl"))
216		if err := tmpl.Execute(w, data); err != nil {
217			fmt.Println(err)
218		}
219	}
220
221	if r.Method == http.MethodPost {
222		err := r.ParseForm()
223		if err != nil {
224			fmt.Println(err)
225		}
226		username := bmStrict.Sanitize(r.FormValue("username"))
227		password := bmStrict.Sanitize(r.FormValue("password"))
228
229		if username == "" || password == "" {
230			w.WriteHeader(http.StatusBadRequest)
231			_, err := w.Write([]byte("No data provided"))
232			if err != nil {
233				fmt.Println(err)
234			}
235			return
236		}
237
238		authorised, err := users.UserAuthorised(h.DbConn, username, password)
239		if err != nil {
240			w.WriteHeader(http.StatusBadRequest)
241			_, err := w.Write([]byte(fmt.Sprintf("Error logging in: %s", err)))
242			if err != nil {
243				fmt.Println(err)
244			}
245			return
246		}
247
248		if !authorised {
249			w.WriteHeader(http.StatusUnauthorized)
250			_, err := w.Write([]byte("Incorrect username or password"))
251			if err != nil {
252				fmt.Println(err)
253			}
254			return
255		}
256
257		session, expiry, err := users.CreateSession(h.DbConn, username)
258		if err != nil {
259			w.WriteHeader(http.StatusBadRequest)
260			_, err := w.Write([]byte(fmt.Sprintf("Error creating session: %s", err)))
261			if err != nil {
262				fmt.Println(err)
263			}
264			return
265		}
266
267		maxAge := int(time.Until(expiry))
268
269		cookie := http.Cookie{
270			Name:     "id",
271			Value:    session,
272			MaxAge:   maxAge,
273			HttpOnly: true,
274			SameSite: http.SameSiteStrictMode,
275			Secure:   true,
276		}
277
278		http.SetCookie(w, &cookie)
279		http.Redirect(w, r, "/", http.StatusSeeOther)
280	}
281}
282
283func (h Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
284	cookie, err := r.Cookie("id")
285	if err != nil {
286		fmt.Println(err)
287	}
288
289	err = users.InvalidateSession(h.DbConn, cookie.Value)
290	if err != nil {
291		fmt.Println(err)
292		_, err = w.Write([]byte(fmt.Sprintf("Error logging out: %s", err)))
293		if err != nil {
294			fmt.Println(err)
295		}
296		return
297	}
298	cookie.MaxAge = -1
299	http.SetCookie(w, cookie)
300	http.Redirect(w, r, "/login", http.StatusSeeOther)
301}
302
303// isAuthorised makes a database request to the sessions table to see if the
304// user has a valid session cookie.
305func (h Handler) isAuthorised(r *http.Request) bool {
306	cookie, err := r.Cookie("id")
307	if err != nil {
308		return false
309	}
310
311	authorised, err := users.SessionAuthorised(h.DbConn, cookie.Value)
312	if err != nil {
313		fmt.Println("Error checking session:", err)
314		return false
315	}
316
317	return authorised
318}
319
320func StaticHandler(writer http.ResponseWriter, request *http.Request) {
321	resource := strings.TrimPrefix(request.URL.Path, "/")
322	if strings.HasSuffix(resource, ".css") {
323		writer.Header().Set("Content-Type", "text/css")
324	} else if strings.HasSuffix(resource, ".js") {
325		writer.Header().Set("Content-Type", "text/javascript")
326	}
327	home, err := fs.ReadFile(resource)
328	if err != nil {
329		fmt.Println(err)
330	}
331	if _, err = io.Writer.Write(writer, home); err != nil {
332		fmt.Println(err)
333	}
334}