http.go (view raw)
1package main
2
3import (
4 "bytes"
5 "database/sql"
6 "fmt"
7 gmi "git.sr.ht/~adnano/go-gemini"
8 "github.com/gorilla/handlers"
9 "github.com/gorilla/sessions"
10 _ "github.com/mattn/go-sqlite3"
11 "golang.org/x/crypto/bcrypt"
12 "html/template"
13 "io"
14 "io/ioutil"
15 "log"
16 "mime"
17 "net/http"
18 "os"
19 "path"
20 "path/filepath"
21 "strings"
22 "time"
23)
24
25var t *template.Template
26var DB *sql.DB
27var SessionStore *sessions.CookieStore
28
29func renderDefaultError(w http.ResponseWriter, statusCode int) {
30 errorMsg := http.StatusText(statusCode)
31 renderError(w, errorMsg, statusCode)
32}
33
34func renderError(w http.ResponseWriter, errorMsg string, statusCode int) {
35 data := struct {
36 PageTitle string
37 StatusCode int
38 ErrorMsg string
39 }{"Error!", statusCode, errorMsg}
40 err := t.ExecuteTemplate(w, "error.html", data)
41 if err != nil { // Shouldn't happen probably
42 http.Error(w, errorMsg, statusCode)
43 }
44}
45
46func rootHandler(w http.ResponseWriter, r *http.Request) {
47 // serve everything inside static directory
48 if r.URL.Path != "/" {
49 fileName := path.Join(c.TemplatesDirectory, "static", filepath.Clean(r.URL.Path))
50 _, err := os.Stat(fileName)
51 if err != nil {
52 renderDefaultError(w, http.StatusNotFound)
53 return
54 }
55 http.ServeFile(w, r, fileName) // TODO better error handling
56 return
57 }
58
59 user := newGetAuthUser(r)
60 indexFiles, err := getIndexFiles(user.IsAdmin)
61 if err != nil {
62 panic(err)
63 }
64 allUsers, err := getActiveUserNames()
65 if err != nil {
66 panic(err)
67 }
68 data := struct {
69 Host string
70 PageTitle string
71 Files []*File
72 Users []string
73 AuthUser AuthUser
74 }{c.Host, c.SiteTitle, indexFiles, allUsers, user}
75 err = t.ExecuteTemplate(w, "index.html", data)
76 if err != nil {
77 panic(err)
78 }
79}
80
81func editFileHandler(w http.ResponseWriter, r *http.Request) {
82 user := newGetAuthUser(r)
83 if !user.LoggedIn {
84 renderDefaultError(w, http.StatusForbidden)
85 return
86 }
87 fileName := filepath.Clean(r.URL.Path[len("/edit/"):])
88 isText := strings.HasPrefix(mime.TypeByExtension(path.Ext(fileName)), "text")
89 filePath := path.Join(c.FilesDirectory, user.Username, fileName)
90
91 if r.Method == "GET" {
92 err := checkIfValidFile(filePath, nil)
93 if err != nil {
94 log.Println(err)
95 renderError(w, err.Error(), http.StatusBadRequest)
96 return
97 }
98 // Create directories if dne
99 f, err := os.OpenFile(filePath, os.O_RDONLY, 0644)
100 var fileBytes []byte
101 if os.IsNotExist(err) || !isText {
102 fileBytes = []byte{}
103 err = nil
104 } else {
105 defer f.Close()
106 fileBytes, err = ioutil.ReadAll(f)
107 }
108 if err != nil {
109 panic(err)
110 }
111 data := struct {
112 FileName string
113 FileText string
114 PageTitle string
115 AuthUser AuthUser
116 Host string
117 IsText bool
118 }{fileName, string(fileBytes), c.SiteTitle, user, c.Host, isText}
119 err = t.ExecuteTemplate(w, "edit_file.html", data)
120 if err != nil {
121 panic(err)
122 }
123 } else if r.Method == "POST" {
124 // get post body
125 r.ParseForm()
126 fileText := r.Form.Get("file_text")
127 // Web form by default gives us CR LF newlines.
128 // Unix files use just LF
129 fileText = strings.ReplaceAll(fileText, "\r\n", "\n")
130 fileBytes := []byte(fileText)
131 err := checkIfValidFile(filePath, fileBytes)
132 if err != nil {
133 log.Println(err)
134 renderError(w, err.Error(), http.StatusBadRequest)
135 return
136 }
137 // create directories if dne
138 os.MkdirAll(path.Dir(filePath), os.ModePerm)
139 if userHasSpace(user.Username, len(fileBytes)) {
140 if isText { // Cant edit binary files here
141 err = ioutil.WriteFile(filePath, fileBytes, 0644)
142 }
143 } else {
144 renderError(w, fmt.Sprintf("Bad Request: Out of file space. Max space: %d.", c.MaxUserBytes), http.StatusBadRequest)
145 return
146 }
147 if err != nil {
148 panic(err)
149 }
150 newName := filepath.Clean(r.Form.Get("rename"))
151 err = checkIfValidFile(newName, fileBytes)
152 if err != nil {
153 log.Println(err)
154 renderError(w, err.Error(), http.StatusBadRequest)
155 return
156 }
157 if newName != fileName {
158 newPath := path.Join(c.FilesDirectory, user.Username, newName)
159 os.MkdirAll(path.Dir(newPath), os.ModePerm)
160 os.Rename(filePath, newPath)
161 fileName = newName
162 }
163 http.Redirect(w, r, path.Join("/edit", fileName), http.StatusSeeOther)
164 }
165}
166
167func uploadFilesHandler(w http.ResponseWriter, r *http.Request) {
168 if r.Method == "POST" {
169 user := newGetAuthUser(r)
170 if !user.LoggedIn {
171 renderDefaultError(w, http.StatusForbidden)
172 return
173 }
174 r.ParseMultipartForm(10 << 6) // why does this not work
175 file, fileHeader, err := r.FormFile("file")
176 fileName := filepath.Clean(fileHeader.Filename)
177 defer file.Close()
178 if err != nil {
179 log.Println(err)
180 renderError(w, err.Error(), http.StatusBadRequest)
181 return
182 }
183 dest, _ := ioutil.ReadAll(file)
184 err = checkIfValidFile(fileName, dest)
185 if err != nil {
186 log.Println(err)
187 renderError(w, err.Error(), http.StatusBadRequest)
188 return
189 }
190 destPath := path.Join(c.FilesDirectory, user.Username, fileName)
191
192 f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE, 0644)
193 if err != nil {
194 panic(err)
195 }
196 defer f.Close()
197 if userHasSpace(user.Username, c.MaxFileBytes) { // Not quite right
198 io.Copy(f, bytes.NewReader(dest))
199 } else {
200 renderError(w, fmt.Sprintf("Bad Request: Out of file space. Max space: %d.", c.MaxUserBytes), http.StatusBadRequest)
201 return
202 }
203 }
204 http.Redirect(w, r, "/my_site", http.StatusSeeOther)
205}
206
207type AuthUser struct {
208 LoggedIn bool
209 Username string
210 IsAdmin bool
211 ImpersonatingUser string // used if impersonating
212}
213
214func newGetAuthUser(r *http.Request) AuthUser {
215 session, _ := SessionStore.Get(r, "cookie-session")
216 user, ok := session.Values["auth_user"].(string)
217 impers, _ := session.Values["impersonating_user"].(string)
218 isAdmin, _ := session.Values["admin"].(bool)
219 return AuthUser{
220 LoggedIn: ok,
221 Username: user,
222 IsAdmin: isAdmin,
223 ImpersonatingUser: impers,
224 }
225}
226
227func mySiteHandler(w http.ResponseWriter, r *http.Request) {
228 user := newGetAuthUser(r)
229 if !user.LoggedIn {
230 renderDefaultError(w, http.StatusForbidden)
231 return
232 }
233 // check auth
234 userFolder := getUserDirectory(user.Username)
235 files, _ := getMyFilesRecursive(userFolder, user.Username)
236 data := struct {
237 Host string
238 PageTitle string
239 Files []*File
240 AuthUser AuthUser
241 }{c.Host, c.SiteTitle, files, user}
242 _ = t.ExecuteTemplate(w, "my_site.html", data)
243}
244
245func myAccountHandler(w http.ResponseWriter, r *http.Request) {
246 user := newGetAuthUser(r)
247 authUser := user.Username
248 if !user.LoggedIn {
249 renderDefaultError(w, http.StatusForbidden)
250 return
251 }
252 me, _ := getUserByName(user.Username)
253 type pageData struct {
254 PageTitle string
255 AuthUser AuthUser
256 Email string
257 Errors []string
258 }
259 data := pageData{"My Account", user, me.Email, nil}
260
261 if r.Method == "GET" {
262 err := t.ExecuteTemplate(w, "me.html", data)
263 if err != nil {
264 panic(err)
265 }
266 } else if r.Method == "POST" {
267 r.ParseForm()
268 newUsername := r.Form.Get("username")
269 errors := []string{}
270 newEmail := r.Form.Get("email")
271 newUsername = strings.ToLower(newUsername)
272 var err error
273 if newEmail != me.Email {
274 _, err = DB.Exec("update user set email = ? where username = ?", newEmail, me.Email)
275 if err != nil {
276 // TODO better error not sql
277 errors = append(errors, err.Error())
278 } else {
279 log.Printf("Changed email for %s from %s to %s", authUser, me.Email, newEmail)
280 }
281 }
282 if newUsername != authUser {
283 // Rename User
284 err = renameUser(authUser, newUsername)
285 if err != nil {
286 log.Println(err)
287 errors = append(errors, "Could not rename user")
288 } else {
289 session, _ := SessionStore.Get(r, "cookie-session")
290 session.Values["auth_user"] = newUsername
291 session.Save(r, w)
292 }
293 }
294 // reset auth
295 user = newGetAuthUser(r)
296 data.Errors = errors
297 data.AuthUser = user
298 data.Email = newEmail
299 _ = t.ExecuteTemplate(w, "me.html", data)
300 }
301}
302
303func archiveHandler(w http.ResponseWriter, r *http.Request) {
304 authUser := newGetAuthUser(r)
305 if !authUser.LoggedIn {
306 renderDefaultError(w, http.StatusForbidden)
307 return
308 }
309 if r.Method == "GET" {
310 userFolder := getUserDirectory(authUser.Username)
311 err := zipit(userFolder, w)
312 if err != nil {
313 panic(err)
314 }
315
316 }
317}
318func loginHandler(w http.ResponseWriter, r *http.Request) {
319 if r.Method == "GET" {
320 // show page
321 data := struct {
322 Error string
323 PageTitle string
324 }{"", "Login"}
325 err := t.ExecuteTemplate(w, "login.html", data)
326 if err != nil {
327 panic(err)
328 }
329 } else if r.Method == "POST" {
330 r.ParseForm()
331 name := r.Form.Get("username")
332 password := r.Form.Get("password")
333 row := DB.QueryRow("SELECT username, password_hash, active, admin FROM user where username = $1 OR email = $1", name)
334 var db_password []byte
335 var username string
336 var active bool
337 var isAdmin bool
338 err := row.Scan(&username, &db_password, &active, &isAdmin)
339 if err != nil {
340 panic(err)
341 }
342 if db_password != nil && !active {
343 data := struct {
344 Error string
345 PageTitle string
346 }{"Your account is not active yet. Pending admin approval", c.SiteTitle}
347 t.ExecuteTemplate(w, "login.html", data)
348 return
349 }
350 if bcrypt.CompareHashAndPassword(db_password, []byte(password)) == nil {
351 log.Println("logged in")
352 session, _ := SessionStore.Get(r, "cookie-session")
353 session.Values["auth_user"] = username
354 session.Values["admin"] = isAdmin
355 session.Save(r, w)
356 http.Redirect(w, r, "/my_site", http.StatusSeeOther)
357 } else {
358 data := struct {
359 Error string
360 PageTitle string
361 }{"Invalid login or password", c.SiteTitle}
362 err := t.ExecuteTemplate(w, "login.html", data)
363 if err != nil {
364 panic(err)
365 }
366 }
367 }
368}
369
370func logoutHandler(w http.ResponseWriter, r *http.Request) {
371 session, _ := SessionStore.Get(r, "cookie-session")
372 impers, ok := session.Values["impersonating_user"].(string)
373 if ok {
374 session.Values["auth_user"] = impers
375 session.Values["impersonating_user"] = nil // TODO expire this automatically
376 // session.Values["admin"] = nil // TODO fix admin
377 } else {
378 session.Options.MaxAge = -1
379 }
380 session.Save(r, w)
381 http.Redirect(w, r, "/", http.StatusSeeOther)
382}
383
384const ok = "-0123456789abcdefghijklmnopqrstuvwxyz"
385
386func isOkUsername(s string) error {
387 if len(s) < 1 {
388 return fmt.Errorf("Username is too short")
389 }
390 if len(s) > 32 {
391 return fmt.Errorf("Username is too long. 32 char max.")
392 }
393 for _, char := range s {
394 if !strings.Contains(ok, strings.ToLower(string(char))) {
395 return fmt.Errorf("Username contains invalid characters. Valid characters include lowercase letters, numbers, and hyphens.")
396 }
397 }
398 return nil
399}
400func registerHandler(w http.ResponseWriter, r *http.Request) {
401 if r.Method == "GET" {
402 data := struct {
403 Host string
404 Errors []string
405 PageTitle string
406 }{c.Host, nil, "Register"}
407 err := t.ExecuteTemplate(w, "register.html", data)
408 if err != nil {
409 panic(err)
410 }
411 } else if r.Method == "POST" {
412 r.ParseForm()
413 email := r.Form.Get("email")
414 password := r.Form.Get("password")
415 errors := []string{}
416 if r.Form.Get("password") != r.Form.Get("password2") {
417 errors = append(errors, "Passwords don't match")
418 }
419 if len(password) < 6 {
420 errors = append(errors, "Password is too short")
421 }
422 username := strings.ToLower(r.Form.Get("username"))
423 err := isOkUsername(username)
424 if err != nil {
425 errors = append(errors, err.Error())
426 }
427 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 8) // TODO handle error
428 if err != nil {
429 panic(err)
430 }
431 reference := r.Form.Get("reference")
432 if len(errors) == 0 {
433 _, err = DB.Exec("insert into user (username, email, password_hash, reference) values ($1, $2, $3, $4)", username, email, string(hashedPassword), reference)
434 if err != nil {
435 errors = append(errors, "Username or email is already used")
436 }
437 }
438 if len(errors) > 0 {
439 data := struct {
440 Host string
441 Errors []string
442 PageTitle string
443 }{c.Host, errors, "Register"}
444 t.ExecuteTemplate(w, "register.html", data)
445 } else {
446 data := struct {
447 Host string
448 Message string
449 PageTitle string
450 }{c.Host, "Registration complete! The server admin will approve your request before you can log in.", "Registration Complete"}
451 t.ExecuteTemplate(w, "message.html", data)
452 }
453 }
454}
455
456func deleteFileHandler(w http.ResponseWriter, r *http.Request) {
457 user := newGetAuthUser(r)
458 if !user.LoggedIn {
459 renderDefaultError(w, http.StatusForbidden)
460 return
461 }
462 filePath := safeGetFilePath(user.Username, r.URL.Path[len("/delete/"):])
463 if r.Method == "POST" {
464 os.Remove(filePath) // TODO handle error
465 }
466 http.Redirect(w, r, "/my_site", http.StatusSeeOther)
467}
468
469func adminHandler(w http.ResponseWriter, r *http.Request) {
470 user := newGetAuthUser(r)
471 if !user.IsAdmin {
472 renderDefaultError(w, http.StatusForbidden)
473 return
474 }
475 allUsers, err := getUsers()
476 if err != nil {
477 log.Println(err)
478 renderDefaultError(w, http.StatusInternalServerError)
479 return
480 }
481 data := struct {
482 Users []User
483 AuthUser AuthUser
484 PageTitle string
485 Host string
486 }{allUsers, user, "Admin", c.Host}
487 err = t.ExecuteTemplate(w, "admin.html", data)
488 if err != nil {
489 panic(err)
490 }
491}
492
493func getFavicon(user string) string {
494 faviconPath := path.Join(c.FilesDirectory, filepath.Clean(user), "favicon.txt")
495 content, err := ioutil.ReadFile(faviconPath)
496 if err != nil {
497 return ""
498 }
499 strcontent := []rune(string(content))
500 if len(strcontent) > 0 {
501 return string(strcontent[0])
502 }
503 return ""
504}
505
506// Server a user's file
507func userFile(w http.ResponseWriter, r *http.Request) {
508 userName := filepath.Clean(strings.Split(r.Host, ".")[0]) // Clean probably unnecessary
509 p := filepath.Clean(r.URL.Path)
510 var isDir bool
511 fileName := path.Join(c.FilesDirectory, userName, p)
512 stat, err := os.Stat(fileName)
513 if stat != nil {
514 isDir = stat.IsDir()
515 }
516 if p == "/" || isDir {
517 fileName = path.Join(fileName, "index.gmi")
518 }
519
520 if strings.HasPrefix(p, "/.hidden") {
521 renderDefaultError(w, http.StatusForbidden)
522 return
523 }
524 if r.URL.Path == "/style.css" {
525 http.ServeFile(w, r, path.Join(c.TemplatesDirectory, "static/style.css"))
526 return
527 }
528
529 _, err = os.Stat(fileName)
530 if os.IsNotExist(err) {
531 renderDefaultError(w, http.StatusNotFound)
532 return
533 }
534
535 // Dumb content negotiation
536 extension := path.Ext(fileName)
537 _, raw := r.URL.Query()["raw"]
538 acceptsGemini := strings.Contains(r.Header.Get("Accept"), "text/gemini")
539 if !raw && !acceptsGemini && (extension == ".gmi" || extension == ".gemini") {
540 file, _ := os.Open(fileName)
541 htmlString := textToHTML(gmi.ParseText(file))
542 favicon := getFavicon(userName)
543 data := struct {
544 SiteBody template.HTML
545 Favicon string
546 PageTitle string
547 }{template.HTML(htmlString), favicon, userName + p}
548 t.ExecuteTemplate(w, "user_page.html", data)
549 } else {
550 http.ServeFile(w, r, fileName)
551 }
552}
553
554func deleteAccountHandler(w http.ResponseWriter, r *http.Request) {
555 user := newGetAuthUser(r)
556 if r.Method == "POST" {
557 r.ParseForm()
558 validate := r.Form.Get("validate-delete")
559 if validate == user.Username {
560 err := deleteUser(user.Username)
561 if err != nil {
562 log.Println(err)
563 renderDefaultError(w, http.StatusInternalServerError)
564 return
565 }
566 logoutHandler(w, r)
567 } else {
568 http.Redirect(w, r, "/me", http.StatusSeeOther)
569 }
570 }
571}
572
573func resetPasswordHandler(w http.ResponseWriter, r *http.Request) {
574 user := newGetAuthUser(r)
575 data := struct {
576 PageTitle string
577 AuthUser AuthUser
578 Error string
579 }{"Reset Password", user, ""}
580 if r.Method == "GET" {
581 err := t.ExecuteTemplate(w, "reset_pass.html", data)
582 if err != nil {
583 panic(err)
584 }
585 } else if r.Method == "POST" {
586 r.ParseForm()
587 enteredCurrPass := r.Form.Get("password")
588 var currPass []byte
589 password1 := r.Form.Get("new_password1")
590 password2 := r.Form.Get("new_password2")
591 row := DB.QueryRow("SELECT password_hash FROM user where username = ?", user.Username)
592 err := row.Scan(&currPass)
593 if password1 != password2 {
594 data.Error = "New passwords do not match"
595 } else if len(password1) < 6 {
596 data.Error = "Password is too short"
597 } else {
598 err = bcrypt.CompareHashAndPassword(currPass, []byte(enteredCurrPass))
599 if err == nil {
600 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password1), 8)
601 if err != nil {
602 panic(err)
603 }
604 _, err = DB.Exec("update user set password_hash = ? where username = ?", hashedPassword, user.Username)
605 if err != nil {
606 panic(err)
607 }
608 http.Redirect(w, r, "/me", http.StatusSeeOther)
609 return
610 } else {
611 data.Error = "That's not your current password"
612 }
613 }
614 err = t.ExecuteTemplate(w, "reset_pass.html", data)
615 if err != nil {
616 panic(err)
617 }
618 }
619}
620
621func adminUserHandler(w http.ResponseWriter, r *http.Request) {
622 user := newGetAuthUser(r)
623 if r.Method == "POST" {
624 if !user.IsAdmin {
625 renderDefaultError(w, http.StatusForbidden)
626 return
627 }
628 components := strings.Split(r.URL.Path, "/")
629 if len(components) < 5 {
630 renderError(w, "Invalid action", http.StatusBadRequest)
631 return
632 }
633 userName := components[3]
634 action := components[4]
635 var err error
636 if action == "activate" {
637 err = activateUser(userName)
638 } else if action == "impersonate" {
639 session, _ := SessionStore.Get(r, "cookie-session")
640 session.Values["auth_user"] = userName
641 session.Values["impersonating_user"] = user.Username
642 session.Save(r, w)
643 log.Printf("User %s impersonated %s", user.Username, userName)
644 http.Redirect(w, r, "/", http.StatusSeeOther)
645 return
646 }
647 if err != nil {
648 log.Println(err)
649 renderDefaultError(w, http.StatusInternalServerError)
650 return
651 }
652 http.Redirect(w, r, "/admin", http.StatusSeeOther)
653 }
654}
655
656func runHTTPServer() {
657 log.Printf("Running http server with hostname %s on port %d. TLS enabled: %t", c.Host, c.HttpPort, c.HttpsEnabled)
658 var err error
659 t, err = template.ParseGlob(path.Join(c.TemplatesDirectory, "*.html"))
660 if err != nil {
661 log.Fatal(err)
662 }
663 serveMux := http.NewServeMux()
664
665 s := strings.SplitN(c.Host, ":", 2)
666 hostname := s[0]
667 port := c.HttpPort
668
669 serveMux.HandleFunc(hostname+"/", rootHandler)
670 serveMux.HandleFunc(hostname+"/my_site", mySiteHandler)
671 serveMux.HandleFunc(hostname+"/me", myAccountHandler)
672 serveMux.HandleFunc(hostname+"/my_site/flounder-archive.zip", archiveHandler)
673 serveMux.HandleFunc(hostname+"/admin", adminHandler)
674 serveMux.HandleFunc(hostname+"/edit/", editFileHandler)
675 serveMux.HandleFunc(hostname+"/upload", uploadFilesHandler)
676 serveMux.Handle(hostname+"/login", limit(http.HandlerFunc(loginHandler)))
677 serveMux.Handle(hostname+"/register", limit(http.HandlerFunc(registerHandler)))
678 serveMux.HandleFunc(hostname+"/logout", logoutHandler)
679 serveMux.HandleFunc(hostname+"/delete/", deleteFileHandler)
680 serveMux.HandleFunc(hostname+"/delete-account", deleteAccountHandler)
681 serveMux.HandleFunc(hostname+"/reset-password", resetPasswordHandler)
682
683 // admin commands
684 serveMux.HandleFunc(hostname+"/admin/user/", adminUserHandler)
685
686 // TODO rate limit login https://github.com/ulule/limiter
687
688 wrapped := (handlers.LoggingHandler(log.Writer(), handlers.RecoveryHandler()(serveMux)))
689
690 // handle user files based on subdomain
691 serveMux.HandleFunc("/", userFile)
692 // login+register functions
693 srv := &http.Server{
694 ReadTimeout: 5 * time.Second,
695 WriteTimeout: 10 * time.Second,
696 IdleTimeout: 120 * time.Second,
697 Addr: fmt.Sprintf(":%d", port),
698 // TLSConfig: tlsConfig,
699 Handler: wrapped,
700 }
701 if c.HttpsEnabled {
702 log.Fatal(srv.ListenAndServeTLS(c.TLSCertFile, c.TLSKeyFile))
703 } else {
704 log.Fatal(srv.ListenAndServe())
705 }
706}