coller/src/server/handlers_api.go
Julien Riou b316c6ef67
All checks were successful
/ pre-commit (push) Successful in 1m16s
feat: Add copier and curl links
Signed-off-by: Julien Riou <julien@riou.xyz>
2025-10-02 09:23:40 +02:00

257 lines
6.4 KiB
Go

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
}
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
}
version := h.version
if version == "" {
version = "latest"
}
http.Redirect(w, r, fmt.Sprintf("https://git.riou.xyz/jriou/%s/releases/download/%s/%s-%s-%s", clientName, version, clientName, os, arch), http.StatusMovedPermanently)
}