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