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}