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}