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