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