package server import ( "bytes" "embed" "encoding/json" "errors" "fmt" "html/template" "io" "log/slog" "net/http" "strconv" "git.riou.xyz/jriou/coller/internal" "github.com/gorilla/mux" ) var passwordLength = internal.MIN_PASSWORD_LENGTH type Server struct { logger *slog.Logger db *Database config *Config version string } func NewServer(logger *slog.Logger, db *Database, config *Config, version string) (*Server, error) { l := logger.With("module", "server") return &Server{ logger: l, db: db, config: config, version: version, }, nil } func (s *Server) SetIDLength(length int) { idLength = length } func (s *Server) SetPasswordLength(length int) { passwordLength = length } type ErrorResponse struct { Message string `json:"message"` Error string `json:"error"` } func (e ErrorResponse) ToJSON() string { b, err := json.Marshal(e) if err == nil { return string(b) } return fmt.Sprintf("{\"message\":\"could not serialize response to JSON\", \"error\":\"%v\"}", err) } func WriteError(w http.ResponseWriter, message string, err error) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, ErrorResponse{ Message: message, Error: fmt.Sprintf("%v", err), }.ToJSON()) } func HeathHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "OK") } type CreateNoteHandler struct { logger *slog.Logger db *Database maxUploadSize int64 } type CreateNotePayload struct { Content string `json:"content"` Password string `json:"password"` Encrypted bool `json:"encrypted"` Expiration int `json:"expiration"` DeleteAfterRead bool `json:"delete_after_read"` } type CreateNoteResponse struct { ID string `json:"id"` } func (h *CreateNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") bodyReader := http.MaxBytesReader(w, r.Body, h.maxUploadSize) defer r.Body.Close() var body CreateNotePayload err := json.NewDecoder(bodyReader).Decode(&body) if err != nil { WriteError(w, "could not decode payload to create note", err) return } content, err := internal.Decode(body.Content) if err != nil { WriteError(w, "could not decode content", err) return } note, err := h.db.Create(content, body.Password, body.Encrypted, body.Expiration, body.DeleteAfterRead) if err != nil { WriteError(w, "could not create note", err) return } w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(CreateNoteResponse{ID: note.ID}) } type GetNoteHandler struct { logger *slog.Logger db *Database } func (h *GetNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") id := mux.Vars(r)["id"] note, err := h.db.Get(id) if err != nil { WriteError(w, "could not get note", err) } else if note == nil { w.WriteHeader(http.StatusNotFound) } else { if note.Encrypted { w.Header().Set("Content-Type", "application/octet-stream") } w.WriteHeader(http.StatusOK) fmt.Fprint(w, string(note.Content)) } } type GetProtectedNoteHandler struct { logger *slog.Logger db *Database } func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") vars := mux.Vars(r) id := vars["id"] password := vars["password"] note, err := h.db.Get(id) if err != nil { WriteError(w, "could not get note", err) return } else if note == nil { w.WriteHeader(http.StatusNotFound) return } if password != "" && note.Encrypted { note.Content, err = internal.Decrypt(note.Content, password) if err != nil { WriteError(w, "could not decrypt note", err) return } } w.WriteHeader(http.StatusOK) fmt.Fprint(w, string(note.Content)) } type PageData struct { Title string Version string Expirations []int Err error URL string } type HomeHandler struct { Templates *template.Template PageData PageData } func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.Templates.ExecuteTemplate(w, "index", h.PageData) } type CreateNoteWithFormHandler struct { Templates *template.Template PageData PageData logger *slog.Logger db *Database maxUploadSize int64 } func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.PageData.Err = nil templateName := "create" h.logger.Debug("parsing multipart form") err := r.ParseMultipartForm(h.maxUploadSize) if err != nil { h.PageData.Err = err h.Templates.ExecuteTemplate(w, templateName, h.PageData) return } h.logger.Debug("parsing content") content := []byte(r.FormValue("content")) h.logger.Debug("parsing file") file, handler, err := r.FormFile("file") if err != nil && !errors.Is(err, http.ErrMissingFile) { h.PageData.Err = err h.Templates.ExecuteTemplate(w, templateName, h.PageData) return } if !errors.Is(err, http.ErrMissingFile) { defer file.Close() h.logger.Debug("checking file size") if handler.Size > h.maxUploadSize { h.PageData.Err = fmt.Errorf("file too large (%d > %d)", handler.Size, h.maxUploadSize) h.Templates.ExecuteTemplate(w, templateName, h.PageData) return } h.logger.Debug("checking file content type") if handler.Header.Get("Content-Type") != "text/plain" { h.PageData.Err = fmt.Errorf("text file expected (got %s)", handler.Header.Get("Content-Type")) h.Templates.ExecuteTemplate(w, templateName, h.PageData) return } h.logger.Debug("reading uploaded file") var fileContent bytes.Buffer n, err := io.Copy(&fileContent, file) if err != nil { h.PageData.Err = err h.Templates.ExecuteTemplate(w, templateName, h.PageData) return } h.logger.Debug("file uploaded", slog.Any("bytes", n)) if n != 0 { content = fileContent.Bytes() } } h.logger.Debug("checking content") if content == nil { h.PageData.Err = fmt.Errorf("empty note") h.Templates.ExecuteTemplate(w, templateName, h.PageData) return } h.logger.Debug("checking inputs") noPassword := r.FormValue("no-password") password := r.FormValue("password") expiration := r.FormValue("expiration") deleteAfterRead := r.FormValue("delete-after-read") if password == "" && noPassword == "" { h.logger.Debug("generating password") password = internal.GenerateChars(passwordLength) } h.logger.Debug("computing expiration") var expirationInt int if expiration == "Expiration" { expirationInt = 0 } else { expirationInt, _ = strconv.Atoi(expiration) } h.logger.Debug("saving note to the database") note, err := h.db.Create(content, password, password != "", expirationInt, deleteAfterRead != "") if err != nil { h.PageData.Err = err h.Templates.ExecuteTemplate(w, templateName, h.PageData) return } h.logger.Debug("building note url") var scheme = "http://" if r.TLS != nil { scheme = "https://" } h.PageData.URL = fmt.Sprintf("%s%s/%s", scheme, r.Host, note.ID) if password != "" { h.PageData.URL += "/" + password } h.logger.Debug("rendering page") h.Templates.ExecuteTemplate(w, "create", h.PageData) } //go:embed templates/* var templatesFS embed.FS func (s *Server) Start() error { r := mux.NewRouter().StrictSlash(true) // API r.HandleFunc("/health", HeathHandler) r.Path("/api/note").Handler(&CreateNoteHandler{logger: s.logger, db: s.db, maxUploadSize: s.config.MaxUploadSize}).Methods("POST") r.Path("/{id:[a-zA-Z0-9]+}/{password:[a-zA-Z0-9]+}").Handler(&GetProtectedNoteHandler{logger: s.logger, db: s.db}).Methods("GET") r.Path("/{id:[a-zA-Z0-9]+}").Handler(&GetNoteHandler{logger: s.logger, db: s.db}).Methods("GET") // Web pages funcs := template.FuncMap{ "HumanDuration": internal.HumanDuration, } p := PageData{ Title: s.config.Title, Expirations: s.config.Expirations, } if s.config.ShowVersion { p.Version = s.version } templates, err := template.New("templates").Funcs(funcs).ParseFS(templatesFS, "templates/*.html") if err != nil { return err } createNoteWithFormHandler := &CreateNoteWithFormHandler{ Templates: templates, PageData: p, logger: s.logger, db: s.db, maxUploadSize: s.config.MaxUploadSize, } r.Path("/create").Handler(createNoteWithFormHandler).Methods("POST") r.Path("/").Handler(&HomeHandler{Templates: templates, PageData: p}).Methods("GET") addr := fmt.Sprintf("%s:%d", s.config.ListenAddress, s.config.ListenPort) return http.ListenAndServe(addr, r) }