package server import ( "encoding/json" "fmt" "log/slog" "net/http" "github.com/gorilla/mux" "golang.org/x/crypto/bcrypt" "git.riou.xyz/jriou/coller/internal" ) type APIErrorResponse struct { Message string `json:"message"` } func (e APIErrorResponse) 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 apiError(level int, w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { // Wrap error for logging if err != nil { err = fmt.Errorf("%v: %w", topLevelErr, err) } else { err = topLevelErr } logger.Error(fmt.Sprintf("%v", err)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(level) fmt.Fprint(w, APIErrorResponse{ Message: fmt.Sprintf("%v", topLevelErr), }.ToJSON()) } func APIError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { apiError(http.StatusInternalServerError, w, logger, topLevelErr, err) } func APIErrorNotFound(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { apiError(http.StatusNotFound, w, logger, topLevelErr, err) } func APIErrorBadRequest(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { apiError(http.StatusBadRequest, w, logger, topLevelErr, err) } func HealthHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "OK") } type CreateNoteHandler struct { logger *slog.Logger db *Database maxUploadSize int64 allowClientEncryptionKey bool allowNoEncryption bool } func (h *CreateNoteHandler) Name() string { return "CreateNoteHandler" } type CreateNotePayload struct { Content string `json:"content"` Password string `json:"password"` EncryptionKey string `json:"encryption_key"` Encrypted bool `json:"encrypted"` Expiration int `json:"expiration"` DeleteAfterRead bool `json:"delete_after_read"` Language string `json:"language"` } type CreateNoteResponse struct { ID int64 `json:"id"` } func (h *CreateNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") logger := h.logger.With("handler", h.Name()) bodyReader := http.MaxBytesReader(w, r.Body, h.maxUploadSize) defer r.Body.Close() var body CreateNotePayload err := json.NewDecoder(bodyReader).Decode(&body) if err != nil { APIError(w, logger, ErrCouldNotDecodePayload, err) return } if !h.allowNoEncryption && !body.Encrypted { APIError(w, logger, ErrEncryptionRequired, nil) return } if !h.allowClientEncryptionKey && body.EncryptionKey != "" { APIError(w, logger, ErrClientEncryptionKeyNotAllowed, nil) return } content, err := internal.Decode(body.Content) if err != nil { APIError(w, logger, ErrCouldNotDecodeContent, err) return } password, err := internal.Decode(body.Password) if err != nil { APIError(w, logger, ErrCouldNotDecodePassword, err) return } note, err := h.db.Create(content, password, body.EncryptionKey, body.Encrypted, body.Expiration, body.DeleteAfterRead, body.Language) if err != nil { APIError(w, logger, ErrCouldNotCreateNote, 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) Name() string { return "GetNoteHandler" } func (h *GetNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") id := mux.Vars(r)["id"] note, err := h.db.Get(id) logger := h.logger.With("handler", h.Name(), "note_id", id) if err != nil { APIError(w, logger, ErrCouldNotFindNote, err) } else if note == nil { APIErrorNotFound(w, logger, ErrNoteDoesNotExist, nil) } else if note.PasswordHash != nil { APIErrorBadRequest(w, logger, ErrNoteIsPasswordProtected, nil) } 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 maxUploadSize int64 } type GetProtectedNotePayload struct { EncryptionKey string `json:"encryption_key"` Password string `json:"password"` } func (h *GetProtectedNoteHandler) Name() string { return "GetProtectedNoteHandler" } func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") vars := mux.Vars(r) id := vars["id"] logger := h.logger.With("handler", h.Name(), "note_id", id) bodyReader := http.MaxBytesReader(w, r.Body, h.maxUploadSize) defer r.Body.Close() var body GetProtectedNotePayload err := json.NewDecoder(bodyReader).Decode(&body) if err != nil { APIError(w, logger, ErrCouldNotDecodePayload, err) return } note, err := h.db.Get(id) if err != nil { APIError(w, logger, ErrCouldNotFindNote, err) return } else if note == nil { w.WriteHeader(http.StatusNotFound) return } if body.EncryptionKey != "" && note.Encrypted { note.Content, err = internal.Decrypt(note.Content, body.EncryptionKey) if err != nil { APIError(w, logger, ErrCouldNotDecryptNote, err) return } } password, err := internal.Decode(body.Password) if err != nil { APIError(w, logger, ErrCouldNotDecodePassword, err) return } if len(note.PasswordHash) > 0 { err := bcrypt.CompareHashAndPassword(note.PasswordHash, password) if err != nil { APIErrorBadRequest(w, logger, ErrInvalidPassword, err) return } } w.WriteHeader(http.StatusOK) fmt.Fprint(w, string(note.Content)) } type ClientHandler struct { logger *slog.Logger version string baseURL string baseDirectory string } func (h *ClientHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.logger.Debug("rendering client redirection") vars := mux.Vars(r) os := vars["os"] arch := vars["arch"] clientName := vars["clientName"] if !internal.InSlice(supportedOSes, os) || !internal.InSlice(supportedArches, arch) || !internal.InSlice(supportedClients, clientName) { w.WriteHeader(http.StatusNotFound) return } // No disclosure of the version running on the server if h.version == "" { http.Redirect(w, r, fmt.Sprintf("%s/%s/%s-%s-%s", h.baseURL, "latest", clientName, os, arch), http.StatusMovedPermanently) return } if h.baseDirectory != "" { // Serve file locally // Example: ./releases/1.2.0/coller-linux-amd64 http.ServeFile(w, r, fmt.Sprintf("%s/%s/%s-%s-%s", h.baseDirectory, h.version, clientName, os, arch)) } else { // Redirect to a download link // Example: https://git.riou.xyz/jriou/coller/releases/download/1.2.0/coller-linux-amd64 http.Redirect(w, r, fmt.Sprintf("%s/%s/%s-%s-%s", h.baseURL, h.version, clientName, os, arch), http.StatusMovedPermanently) } }