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