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}