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