all repos — flounder @ ade0a38005835f48624cd3c356465f7456c79a2f

A small site builder for the Gemini protocol

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	authd, _, isAdmin := getAuthUser(r)
 60	indexFiles, err := getIndexFiles()
 61	if err != nil {
 62		log.Println(err)
 63		renderDefaultError(w, http.StatusInternalServerError)
 64		return
 65	}
 66	allUsers, err := getActiveUserNames()
 67	if err != nil {
 68		log.Println(err)
 69		renderDefaultError(w, http.StatusInternalServerError)
 70		return
 71	}
 72	data := struct {
 73		Host      string
 74		PageTitle string
 75		Files     []*File
 76		Users     []string
 77		LoggedIn  bool
 78		IsAdmin   bool
 79	}{c.Host, c.SiteTitle, indexFiles, allUsers, authd, isAdmin}
 80	err = t.ExecuteTemplate(w, "index.html", data)
 81	if err != nil {
 82		log.Println(err)
 83		renderDefaultError(w, http.StatusInternalServerError)
 84		return
 85	}
 86}
 87
 88func editFileHandler(w http.ResponseWriter, r *http.Request) {
 89	ok, authUser, _ := getAuthUser(r)
 90	if !ok {
 91		renderDefaultError(w, http.StatusForbidden)
 92		return
 93	}
 94	fileName := filepath.Clean(r.URL.Path[len("/edit/"):])
 95	isText := strings.HasPrefix(mime.TypeByExtension(path.Ext(fileName)), "text")
 96	filePath := path.Join(c.FilesDirectory, authUser, fileName)
 97
 98	if r.Method == "GET" {
 99		err := checkIfValidFile(filePath, nil)
100		if err != nil {
101			log.Println(err)
102			renderError(w, err.Error(), http.StatusBadRequest)
103			return
104		}
105		// Create directories if dne
106		f, err := os.OpenFile(filePath, os.O_RDONLY, 0644)
107		var fileBytes []byte
108		if os.IsNotExist(err) || !isText {
109			fileBytes = []byte{}
110			err = nil
111		} else {
112			defer f.Close()
113			fileBytes, err = ioutil.ReadAll(f)
114		}
115		if err != nil {
116			log.Println(err)
117			renderDefaultError(w, http.StatusInternalServerError)
118			return
119		}
120		data := struct {
121			FileName  string
122			FileText  string
123			PageTitle string
124			AuthUser  string
125			Host      string
126			IsText    bool
127		}{fileName, string(fileBytes), c.SiteTitle, authUser, c.Host, isText}
128		err = t.ExecuteTemplate(w, "edit_file.html", data)
129		if err != nil {
130			log.Println(err)
131			renderDefaultError(w, http.StatusInternalServerError)
132			return
133		}
134	} else if r.Method == "POST" {
135		// get post body
136		r.ParseForm()
137		fileBytes := []byte(r.Form.Get("file_text"))
138		err := checkIfValidFile(filePath, fileBytes)
139		if err != nil {
140			log.Println(err)
141			renderError(w, err.Error(), http.StatusBadRequest)
142			return
143		}
144		// create directories if dne
145		os.MkdirAll(path.Dir(filePath), os.ModePerm)
146		if userHasSpace(authUser, len(fileBytes)) {
147			if isText { // Cant edit binary files here
148				err = ioutil.WriteFile(filePath, fileBytes, 0644)
149			}
150		} else {
151			renderError(w, fmt.Sprintf("Bad Request: Out of file space. Max space: %d.", c.MaxUserBytes), http.StatusBadRequest)
152			return
153		}
154		if err != nil {
155			log.Println(err)
156			renderDefaultError(w, http.StatusInternalServerError)
157			return
158		}
159		newName := filepath.Clean(r.Form.Get("rename"))
160		err = checkIfValidFile(newName, fileBytes)
161		if err != nil {
162			log.Println(err)
163			renderError(w, err.Error(), http.StatusBadRequest)
164			return
165		}
166		if newName != fileName {
167			newPath := path.Join(c.FilesDirectory, authUser, newName)
168			os.MkdirAll(path.Dir(newPath), os.ModePerm)
169			os.Rename(filePath, newPath)
170			fileName = newName
171		}
172		http.Redirect(w, r, path.Join("/edit", fileName), http.StatusSeeOther)
173	}
174}
175
176func uploadFilesHandler(w http.ResponseWriter, r *http.Request) {
177	if r.Method == "POST" {
178		session, _ := SessionStore.Get(r, "cookie-session")
179		authUser, ok := session.Values["auth_user"].(string)
180		if !ok {
181			renderDefaultError(w, http.StatusForbidden)
182			return
183		}
184		r.ParseMultipartForm(10 << 6) // why does this not work
185		file, fileHeader, err := r.FormFile("file")
186		fileName := filepath.Clean(fileHeader.Filename)
187		defer file.Close()
188		if err != nil {
189			log.Println(err)
190			renderError(w, err.Error(), http.StatusBadRequest)
191			return
192		}
193		dest, _ := ioutil.ReadAll(file)
194		err = checkIfValidFile(fileName, dest)
195		if err != nil {
196			log.Println(err)
197			renderError(w, err.Error(), http.StatusBadRequest)
198			return
199		}
200		destPath := path.Join(c.FilesDirectory, authUser, fileName)
201
202		f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE, 0644)
203		if err != nil {
204			log.Println(err)
205			renderDefaultError(w, http.StatusInternalServerError)
206			return
207		}
208		defer f.Close()
209		if userHasSpace(authUser, c.MaxFileBytes) { // Not quite right
210			io.Copy(f, bytes.NewReader(dest))
211		} else {
212			renderError(w, fmt.Sprintf("Bad Request: Out of file space. Max space: %d.", c.MaxUserBytes), http.StatusBadRequest)
213			return
214		}
215	}
216	http.Redirect(w, r, "/my_site", http.StatusSeeOther)
217}
218
219func getAuthUser(r *http.Request) (bool, string, bool) {
220	session, _ := SessionStore.Get(r, "cookie-session")
221	user, ok := session.Values["auth_user"].(string)
222	isAdmin, _ := session.Values["admin"].(bool)
223	return ok, user, isAdmin
224}
225func deleteFileHandler(w http.ResponseWriter, r *http.Request) {
226	authd, authUser, _ := getAuthUser(r)
227	if !authd {
228		renderDefaultError(w, http.StatusForbidden)
229		return
230	}
231	fileName := filepath.Clean(r.URL.Path[len("/delete/"):])
232	filePath := path.Join(c.FilesDirectory, authUser, fileName)
233	if r.Method == "POST" {
234		os.Remove(filePath) // suppress error
235	}
236	http.Redirect(w, r, "/my_site", http.StatusSeeOther)
237}
238
239func mySiteHandler(w http.ResponseWriter, r *http.Request) {
240	authd, authUser, isAdmin := getAuthUser(r)
241	if !authd {
242		renderDefaultError(w, http.StatusForbidden)
243		return
244	}
245	// check auth
246	userFolder := path.Join(c.FilesDirectory, authUser)
247	files, _ := getMyFilesRecursive(userFolder, authUser)
248	data := struct {
249		Host      string
250		PageTitle string
251		AuthUser  string
252		Files     []*File
253		LoggedIn  bool
254		IsAdmin   bool
255	}{c.Host, c.SiteTitle, authUser, files, authd, isAdmin}
256	_ = t.ExecuteTemplate(w, "my_site.html", data)
257}
258
259func archiveHandler(w http.ResponseWriter, r *http.Request) {
260	authd, authUser, _ := getAuthUser(r)
261	if !authd {
262		renderDefaultError(w, http.StatusForbidden)
263		return
264	}
265	if r.Method == "GET" {
266		userFolder := filepath.Join(c.FilesDirectory, filepath.Clean(authUser))
267		err := zipit(userFolder, w)
268		if err != nil {
269			log.Println(err)
270			renderDefaultError(w, http.StatusInternalServerError)
271			return
272		}
273
274	}
275}
276func loginHandler(w http.ResponseWriter, r *http.Request) {
277	if r.Method == "GET" {
278		// show page
279		data := struct {
280			Error     string
281			PageTitle string
282		}{"", "Login"}
283		err := t.ExecuteTemplate(w, "login.html", data)
284		if err != nil {
285			log.Println(err)
286			renderDefaultError(w, http.StatusInternalServerError)
287			return
288		}
289	} else if r.Method == "POST" {
290		r.ParseForm()
291		name := r.Form.Get("username")
292		password := r.Form.Get("password")
293		row := DB.QueryRow("SELECT username, password_hash, active, admin FROM user where username = $1 OR email = $1", name)
294		var db_password []byte
295		var username string
296		var active bool
297		var isAdmin bool
298		_ = row.Scan(&username, &db_password, &active, &isAdmin)
299		if db_password != nil && !active {
300			data := struct {
301				Error     string
302				PageTitle string
303			}{"Your account is not active yet. Pending admin approval", c.SiteTitle}
304			t.ExecuteTemplate(w, "login.html", data)
305			return
306		}
307		if bcrypt.CompareHashAndPassword(db_password, []byte(password)) == nil {
308			log.Println("logged in")
309			session, _ := SessionStore.Get(r, "cookie-session")
310			session.Values["auth_user"] = username
311			session.Values["admin"] = isAdmin
312			session.Save(r, w)
313			http.Redirect(w, r, "/my_site", http.StatusSeeOther)
314		} else {
315			data := struct {
316				Error     string
317				PageTitle string
318			}{"Invalid login or password", c.SiteTitle}
319			err := t.ExecuteTemplate(w, "login.html", data)
320			if err != nil {
321				log.Println(err)
322				renderDefaultError(w, http.StatusInternalServerError)
323				return
324			}
325		}
326	}
327}
328
329func logoutHandler(w http.ResponseWriter, r *http.Request) {
330	session, _ := SessionStore.Get(r, "cookie-session")
331	session.Options.MaxAge = -1
332	session.Save(r, w)
333	http.Redirect(w, r, "/", http.StatusSeeOther)
334}
335
336const ok = "-0123456789abcdefghijklmnopqrstuvwxyz"
337
338func isOkUsername(s string) bool {
339	if len(s) < 1 {
340		return false
341	}
342	if len(s) > 31 {
343		return false
344	}
345	for _, char := range s {
346		if !strings.Contains(ok, strings.ToLower(string(char))) {
347			return false
348		}
349	}
350	return true
351}
352func registerHandler(w http.ResponseWriter, r *http.Request) {
353	if r.Method == "GET" {
354		data := struct {
355			Host      string
356			Errors    []string
357			PageTitle string
358		}{c.Host, nil, "Register"}
359		err := t.ExecuteTemplate(w, "register.html", data)
360		if err != nil {
361			log.Println(err)
362			renderDefaultError(w, http.StatusInternalServerError)
363			return
364		}
365	} else if r.Method == "POST" {
366		r.ParseForm()
367		email := r.Form.Get("email")
368		password := r.Form.Get("password")
369		errors := []string{}
370		if !strings.Contains(email, "@") {
371			errors = append(errors, "Invalid Email")
372		}
373		if r.Form.Get("password") != r.Form.Get("password2") {
374			errors = append(errors, "Passwords don't match")
375		}
376		if len(password) < 6 {
377			errors = append(errors, "Password is too short")
378		}
379		username := strings.ToLower(r.Form.Get("username"))
380		if !isOkUsername(username) {
381			errors = append(errors, "Username is invalid: can only contain letters, numbers and hypens. Maximum 32 characters.")
382		}
383		hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 8) // TODO handle error
384		reference := r.Form.Get("reference")
385		if len(errors) == 0 {
386			_, err = DB.Exec("insert into user (username, email, password_hash, reference) values ($1, $2, $3, $4)", username, email, string(hashedPassword), reference)
387			if err != nil {
388				log.Println(err)
389				errors = append(errors, "Username or email is already used")
390			}
391		}
392		if len(errors) > 0 {
393			data := struct {
394				Host      string
395				Errors    []string
396				PageTitle string
397			}{c.Host, errors, "Register"}
398			t.ExecuteTemplate(w, "register.html", data)
399		} else {
400			data := struct {
401				Host      string
402				Message   string
403				PageTitle string
404			}{c.Host, "Registration complete! The server admin will approve your request before you can log in.", "Registration Complete"}
405			t.ExecuteTemplate(w, "message.html", data)
406		}
407	}
408}
409
410func adminHandler(w http.ResponseWriter, r *http.Request) {
411	_, _, isAdmin := getAuthUser(r)
412	if !isAdmin {
413		renderDefaultError(w, http.StatusForbidden)
414		return
415	}
416	allUsers, err := getUsers()
417	if err != nil {
418		log.Println(err)
419		renderDefaultError(w, http.StatusInternalServerError)
420		return
421	}
422	data := struct {
423		Users     []User
424		LoggedIn  bool
425		IsAdmin   bool
426		PageTitle string
427		Host      string
428	}{allUsers, true, true, "Admin", c.Host}
429	err = t.ExecuteTemplate(w, "admin.html", data)
430	if err != nil {
431		log.Println(err)
432		renderDefaultError(w, http.StatusInternalServerError)
433		return
434	}
435}
436
437func getFavicon(user string) string {
438	faviconPath := path.Join(c.FilesDirectory, filepath.Clean(user), "favicon.txt")
439	content, err := ioutil.ReadFile(faviconPath)
440	if err != nil {
441		return ""
442	}
443	strcontent := []rune(string(content))
444	if len(strcontent) > 0 {
445		return string(strcontent[0])
446	}
447	return ""
448}
449
450// Server a user's file
451func userFile(w http.ResponseWriter, r *http.Request) {
452	userName := filepath.Clean(strings.Split(r.Host, ".")[0]) // Clean probably unnecessary
453	p := filepath.Clean(r.URL.Path)
454	var isDir bool
455	fileName := path.Join(c.FilesDirectory, userName, p)
456	stat, err := os.Stat(fileName)
457	if stat != nil {
458		isDir = stat.IsDir()
459	}
460	if p == "/" || isDir {
461		fileName = path.Join(fileName, "index.gmi")
462	}
463
464	if strings.HasPrefix(p, "/.hidden") {
465		renderDefaultError(w, http.StatusForbidden)
466		return
467	}
468	if r.URL.Path == "/style.css" {
469		http.ServeFile(w, r, path.Join(c.TemplatesDirectory, "static/style.css"))
470		return
471	}
472
473	_, err = os.Stat(fileName)
474	if os.IsNotExist(err) {
475		renderDefaultError(w, http.StatusNotFound)
476		return
477	}
478
479	// Dumb content negotiation
480	extension := path.Ext(fileName)
481	_, raw := r.URL.Query()["raw"]
482	acceptsGemini := strings.Contains(r.Header.Get("Accept"), "text/gemini")
483	if !raw && !acceptsGemini && (extension == ".gmi" || extension == ".gemini") {
484		file, _ := os.Open(fileName)
485		htmlString := textToHTML(gmi.ParseText(file))
486		favicon := getFavicon(userName)
487		data := struct {
488			SiteBody  template.HTML
489			Favicon   string
490			PageTitle string
491		}{template.HTML(htmlString), favicon, userName + p}
492		t.ExecuteTemplate(w, "user_page.html", data)
493	} else {
494		http.ServeFile(w, r, fileName)
495	}
496}
497
498func adminUserHandler(w http.ResponseWriter, r *http.Request) {
499	_, _, isAdmin := getAuthUser(r)
500	if r.Method == "POST" {
501		if !isAdmin {
502			renderDefaultError(w, http.StatusForbidden)
503			return
504		}
505		components := strings.Split(r.URL.Path, "/")
506		if len(components) < 5 {
507			renderError(w, "Invalid action", http.StatusBadRequest)
508			return
509		}
510		userName := components[3]
511		action := components[4]
512		var err error
513		if action == "activate" {
514			err = activateUser(userName)
515		} else if action == "delete" {
516			err = deleteUser(userName)
517		}
518		if err != nil {
519			log.Println(err)
520			renderDefaultError(w, http.StatusInternalServerError)
521			return
522		}
523		http.Redirect(w, r, "/admin", http.StatusSeeOther)
524	}
525}
526
527func runHTTPServer() {
528	log.Printf("Running http server with hostname %s on port %d. TLS enabled: %t", c.Host, c.HttpPort, c.HttpsEnabled)
529	var err error
530	t, err = template.ParseGlob(path.Join(c.TemplatesDirectory, "*.html"))
531	if err != nil {
532		log.Fatal(err)
533	}
534	serveMux := http.NewServeMux()
535
536	s := strings.SplitN(c.Host, ":", 2)
537	hostname := s[0]
538	port := c.HttpPort
539
540	serveMux.HandleFunc(hostname+"/", rootHandler)
541	serveMux.HandleFunc(hostname+"/my_site", mySiteHandler)
542	serveMux.HandleFunc(hostname+"/my_site/flounder-archive.zip", archiveHandler)
543	serveMux.HandleFunc(hostname+"/admin", adminHandler)
544	serveMux.HandleFunc(hostname+"/edit/", editFileHandler)
545	serveMux.HandleFunc(hostname+"/upload", uploadFilesHandler)
546	serveMux.HandleFunc(hostname+"/login", loginHandler)
547	serveMux.HandleFunc(hostname+"/logout", logoutHandler)
548	serveMux.HandleFunc(hostname+"/register", registerHandler)
549	serveMux.HandleFunc(hostname+"/delete/", deleteFileHandler)
550
551	// admin commands
552	serveMux.HandleFunc(hostname+"/admin/user/", adminUserHandler)
553
554	// TODO rate limit login https://github.com/ulule/limiter
555
556	wrapped := handlers.LoggingHandler(log.Writer(), serveMux)
557
558	// handle user files based on subdomain
559	serveMux.HandleFunc("/", userFile)
560	// login+register functions
561	srv := &http.Server{
562		ReadTimeout:  5 * time.Second,
563		WriteTimeout: 10 * time.Second,
564		IdleTimeout:  120 * time.Second,
565		Addr:         fmt.Sprintf(":%d", port),
566		// TLSConfig:    tlsConfig,
567		Handler: wrapped,
568	}
569	if c.HttpsEnabled {
570		log.Fatal(srv.ListenAndServeTLS(c.TLSCertFile, c.TLSKeyFile))
571	} else {
572		log.Fatal(srv.ListenAndServe())
573	}
574}