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	type stuff struct {
 56		Version  string
 57		Projects []project.Project
 58	}
 59
 60	data := stuff{
 61		Version:  *h.Version,
 62		Projects: projectsWithReleases,
 63	}
 64
 65	tmpl := template.Must(template.ParseFS(fs, "static/home.html"))
 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			tmpl := template.Must(template.ParseFS(fs, "static/new.html"))
 81			if err := tmpl.Execute(w, nil); 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			tmpl := template.Must(template.ParseFS(fs, "static/select-release.html"))
143			if err := tmpl.Execute(w, proj); err != nil {
144				fmt.Println(err)
145			}
146		} else if action == "delete" {
147			submittedID := params.Get("id")
148			if submittedID == "" {
149				w.WriteHeader(http.StatusBadRequest)
150				_, err := w.Write([]byte("No URL provided"))
151				if err != nil {
152					fmt.Println(err)
153				}
154				return
155			}
156
157			project.Untrack(h.DbConn, h.Mu, submittedID)
158			http.Redirect(w, r, "/", http.StatusSeeOther)
159		}
160	}
161
162	if r.Method == http.MethodPost {
163		err := r.ParseForm()
164		if err != nil {
165			fmt.Println(err)
166		}
167		idValue := bmStrict.Sanitize(r.FormValue("id"))
168		nameValue := bmStrict.Sanitize(r.FormValue("name"))
169		urlValue := bmStrict.Sanitize(r.FormValue("url"))
170		forgeValue := bmStrict.Sanitize(r.FormValue("forge"))
171		releaseValue := bmStrict.Sanitize(r.FormValue("release"))
172
173		// If releaseValue is not empty, we're updating an existing project
174		if idValue != "" && nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue != "" {
175			project.Track(h.DbConn, h.Mu, h.ManualRefresh, nameValue, urlValue, forgeValue, releaseValue)
176			http.Redirect(w, r, "/", http.StatusSeeOther)
177			return
178		}
179
180		// If releaseValue is empty, we're creating a new project
181		if idValue == "" && nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue == "" {
182			http.Redirect(w, r, "/new?action=yoink&name="+url.QueryEscape(nameValue)+"&url="+url.QueryEscape(urlValue)+"&forge="+url.QueryEscape(forgeValue), http.StatusSeeOther)
183			return
184		}
185
186		w.WriteHeader(http.StatusBadRequest)
187		_, err = w.Write([]byte("No data provided"))
188		if err != nil {
189			fmt.Println(err)
190		}
191	}
192}
193
194func (h Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
195	if r.Method == http.MethodGet {
196		if h.isAuthorised(r) {
197			http.Redirect(w, r, "/", http.StatusSeeOther)
198			return
199		}
200
201		login, err := fs.ReadFile("static/login.html")
202		if err != nil {
203			fmt.Println("Error reading login.html:", err)
204		}
205
206		if _, err := io.WriteString(w, string(login)); err != nil {
207			fmt.Println(err)
208		}
209	}
210
211	if r.Method == http.MethodPost {
212		err := r.ParseForm()
213		if err != nil {
214			fmt.Println(err)
215		}
216		username := bmStrict.Sanitize(r.FormValue("username"))
217		password := bmStrict.Sanitize(r.FormValue("password"))
218
219		if username == "" || password == "" {
220			w.WriteHeader(http.StatusBadRequest)
221			_, err := w.Write([]byte("No data provided"))
222			if err != nil {
223				fmt.Println(err)
224			}
225			return
226		}
227
228		authorised, err := users.UserAuthorised(h.DbConn, username, password)
229		if err != nil {
230			w.WriteHeader(http.StatusBadRequest)
231			_, err := w.Write([]byte(fmt.Sprintf("Error logging in: %s", err)))
232			if err != nil {
233				fmt.Println(err)
234			}
235			return
236		}
237
238		if !authorised {
239			w.WriteHeader(http.StatusUnauthorized)
240			_, err := w.Write([]byte("Incorrect username or password"))
241			if err != nil {
242				fmt.Println(err)
243			}
244			return
245		}
246
247		session, expiry, err := users.CreateSession(h.DbConn, username)
248		if err != nil {
249			w.WriteHeader(http.StatusBadRequest)
250			_, err := w.Write([]byte(fmt.Sprintf("Error creating session: %s", err)))
251			if err != nil {
252				fmt.Println(err)
253			}
254			return
255		}
256
257		maxAge := int(time.Until(expiry))
258
259		cookie := http.Cookie{
260			Name:     "id",
261			Value:    session,
262			MaxAge:   maxAge,
263			HttpOnly: true,
264			SameSite: http.SameSiteStrictMode,
265			Secure:   true,
266		}
267
268		http.SetCookie(w, &cookie)
269		http.Redirect(w, r, "/", http.StatusSeeOther)
270	}
271}
272
273func (h Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
274	cookie, err := r.Cookie("id")
275	if err != nil {
276		fmt.Println(err)
277	}
278
279	err = users.InvalidateSession(h.DbConn, cookie.Value)
280	if err != nil {
281		fmt.Println(err)
282		_, err = w.Write([]byte(fmt.Sprintf("Error logging out: %s", err)))
283		if err != nil {
284			fmt.Println(err)
285		}
286		return
287	}
288	cookie.MaxAge = -1
289	http.SetCookie(w, cookie)
290	http.Redirect(w, r, "/login", http.StatusSeeOther)
291}
292
293// isAuthorised makes a database request to the sessions table to see if the
294// user has a valid session cookie.
295func (h Handler) isAuthorised(r *http.Request) bool {
296	cookie, err := r.Cookie("id")
297	if err != nil {
298		return false
299	}
300
301	authorised, err := users.SessionAuthorised(h.DbConn, cookie.Value)
302	if err != nil {
303		fmt.Println("Error checking session:", err)
304		return false
305	}
306
307	return authorised
308}
309
310func StaticHandler(writer http.ResponseWriter, request *http.Request) {
311	resource := strings.TrimPrefix(request.URL.Path, "/")
312	if strings.HasSuffix(resource, ".css") {
313		writer.Header().Set("Content-Type", "text/css")
314	} else if strings.HasSuffix(resource, ".js") {
315		writer.Header().Set("Content-Type", "text/javascript")
316	}
317	home, err := fs.ReadFile(resource)
318	if err != nil {
319		fmt.Println(err)
320	}
321	if _, err = io.WriteString(writer, string(home)); err != nil {
322		fmt.Println(err)
323	}
324}