forked from jriou/coller
- Add `clients_base_url` to define an alternative URL to download clients - Rename `clients_directory` to `clients_base_directory` to download clients locally based on the version. If `show_version` is disabled and `clients_base_directory` are both defined, a redirection to a remote URL with the "latest" version is chosen in order to avoid to disclose the server version. Fixes #43. Signed-off-by: Julien Riou <julien@riou.xyz>
268 lines
6.9 KiB
Go
268 lines
6.9 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
|
|
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)
|
|
}
|
|
}
|