From 9e0254c0b51e69bd9d890ed88d09f7d0e223c35e Mon Sep 17 00:00:00 2001 From: Julien Riou Date: Sat, 27 Sep 2025 08:35:26 +0200 Subject: [PATCH] feat: Add password protection Fixes #37. BREAKING CHANGE: API routes are prefixed by /api/note. Signed-off-by: Julien Riou --- src/cmd/coller/main.go | 30 +- src/cmd/collerd/README.md | 26 +- src/cmd/copier/main.go | 47 ++- src/go.mod | 14 +- src/go.sum | 10 + src/server/config.go | 8 +- src/server/db.go | 14 +- src/server/handlers_api.go | 45 ++- src/server/handlers_web.go | 384 +++++++++++++++++++++- src/server/note.go | 1 + src/server/server.go | 78 +++-- src/server/templates/error.html | 8 + src/server/templates/index.html | 14 +- src/server/templates/note.html | 106 +++--- src/server/templates/protectedNote.html | 21 ++ src/server/templates/unprotectedNote.html | 42 +++ 16 files changed, 713 insertions(+), 135 deletions(-) create mode 100644 src/server/templates/error.html create mode 100644 src/server/templates/protectedNote.html create mode 100644 src/server/templates/unprotectedNote.html diff --git a/src/cmd/coller/main.go b/src/cmd/coller/main.go index 445ea14..c300e0e 100644 --- a/src/cmd/coller/main.go +++ b/src/cmd/coller/main.go @@ -32,6 +32,7 @@ type NotePayload struct { Expiration int `json:"expiration,omitempty"` DeleteAfterRead bool `json:"delete_after_read,omitempty"` Language string `json:"language"` + Password string `json:"password"` } type NoteResponse struct { @@ -76,6 +77,7 @@ func handleMain() int { bearer := flag.String("bearer", os.Getenv("COLLER_BEARER"), "Bearer token") askBearer := flag.Bool("ask-bearer", false, "Read bearer token from input") language := flag.String("language", "", "Language of the note") + password := flag.String("password", os.Getenv("COLLER_PASSWORD"), "Password to access the note") flag.Parse() @@ -172,6 +174,9 @@ func handleMain() int { if *language != "" { p.Language = *language } + if *password != "" { + p.Password = *password + } if *encryptionKey != "" { logger.Debug("validating encryption key") @@ -242,21 +247,24 @@ func handleMain() int { logger.Debug("finding note location") var location string noteURL := *url + "/" + jsonBody.ID - if *encryptionKey != "" { - if *copier { - location = fmt.Sprintf("copier -encryption-key %s %s", *encryptionKey, noteURL) - } else { - if *html { - location = fmt.Sprintf("%s/%s.html", noteURL, *encryptionKey) - } else { - location = fmt.Sprintf("%s/%s", noteURL, *encryptionKey) - } + if *copier { + location = "copier" + if *encryptionKey != "" { + location += " -encryption-key " + *encryptionKey } + if *password != "" { + location += " -password '" + *password + "'" + } + location += " " + noteURL } else { + location = noteURL + if *encryptionKey != "" { + location += "/" + *encryptionKey + } if *html { - location = fmt.Sprintf("%s.html", noteURL) + location += ".html" } else { - location = noteURL + location += "/raw" } } diff --git a/src/cmd/collerd/README.md b/src/cmd/collerd/README.md index 4ad8b1e..b33d207 100644 --- a/src/cmd/collerd/README.md +++ b/src/cmd/collerd/README.md @@ -22,6 +22,8 @@ The file format is **JSON**: * **encryption_key_length** (int): Number of characters for generated encryption key (default 16) * **allow_client_encryption_key** (bool): Allow encryption key provided by the client on the web UI * **allow_no_encryption** (bool): Allow notes without encryption +* **enable_password_encryption** (bool): Enable password to protect notes (default true) +* **enable_upload_file_button** (bool): Display the upload file button in the UI (default true) * **expiration_interval** (int): Number of seconds to wait between two expiration runs * **listen_address** (string): Address to listen for the web server (default "0.0.0.0") * **listen_port** (int): Port to listen for the web server (default 8080) @@ -35,7 +37,6 @@ The file format is **JSON**: * **observation_internal** (int): Number of seconds to wait between two observations (default 60) * **languages** ([]string): List of supported [languages](https://github.com/microsoft/monaco-editor/tree/main/src/basic-languages) * **language** (string): Default language (default "text") -* **enable_upload_file_button** (bool): Display the upload file button in the UI (default true) * **tls_cert_file** (string): Path to TLS certificate file to enable HTTPS * **tls_key_file** (string): Path to TLS key file to enable HTTPS * **bootstrap_directory** (string): Serve [Bootstrap](https://getbootstrap.com/) assets from this local directory (ex: "./node_modules/bootstrap/dist"). See **Dependencies** for details. @@ -64,19 +65,38 @@ Response (JSON): * **id** (string): ID of the note -### GET /\/\ +### GET /api/note/\/\ > [!WARNING] > Potential encryption key leak Return content of a note encrypted by the given encryption key. -### GET /\ +### POST /api/note/\/\ + +> [!WARNING] +> Potential encryption key leak + +Return content of a protected note encrypted by the given encryption key. + +Body (JSON): +* **password** (string): password used to protect the note (required) + +### GET /api/note/\ Return content of a note. If the note is encrypted, the encrypted value is returned (application/octet-stream). Otherwise, the text is returned (text/plain). +### POST /api/note/\ + +Return content of a protected note. + +If the note is encrypted, the encrypted value is returned (application/octet-stream). Otherwise, the text is returned (text/plain). + +Body (JSON): +* **password** (string): password used to protect the note (required) + ### Errors Errors return **500 Server Internal Error** with the **JSON** payload: diff --git a/src/cmd/copier/main.go b/src/cmd/copier/main.go index 5924936..867f852 100644 --- a/src/cmd/copier/main.go +++ b/src/cmd/copier/main.go @@ -1,11 +1,14 @@ package main import ( + "bytes" + "encoding/json" "flag" "fmt" "io" "log/slog" "net/http" + "net/url" "os" "syscall" @@ -21,6 +24,10 @@ var ( GitCommit string ) +type NotePayload struct { + Password string `json:"password"` +} + func handleMain() int { flag.Usage = usage @@ -34,6 +41,7 @@ func handleMain() int { fileName := flag.String("file", "", "Write content of the note to a file") bearer := flag.String("bearer", os.Getenv("COLLER_BEARER"), "Bearer token") askBearer := flag.Bool("ask-bearer", false, "Read bearer token from input") + password := flag.String("password", os.Getenv("COLLER_PASSWORD"), "Password to access the note") flag.Parse() @@ -47,7 +55,7 @@ func handleMain() int { return internal.RC_ERROR } - url := flag.Args()[0] + rawURL := flag.Args()[0] var level slog.Level if *debug { @@ -81,21 +89,50 @@ func handleMain() int { fmt.Print("\n") } - logger.Debug("creating http request") - req, err := http.NewRequest("GET", url, nil) + logger.Debug("parsing url", slog.Any("url", rawURL)) + u, err := url.Parse(rawURL) if err != nil { - return internal.ReturnError(logger, "could not create request", err) + return internal.ReturnError(logger, "could not parse url", err) } + u.Path = "api/note" + u.Path + + rawURL = u.String() + + logger.Debug("creating http request") + var req *http.Request + if *password != "" { + body := &NotePayload{ + Password: *password, + } + payload, err := json.Marshal(body) + if err != nil { + return internal.ReturnError(logger, "could not create note payload", err) + } + req, err = http.NewRequest("POST", rawURL, bytes.NewBuffer(payload)) + if err != nil { + return internal.ReturnError(logger, "could not create request", err) + } + } else { + req, err = http.NewRequest("GET", rawURL, nil) + if err != nil { + return internal.ReturnError(logger, "could not create request", err) + } + } + if *bearer != "" { req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *bearer)) } - logger.Debug("parsing url", slog.Any("url", url)) + logger.Debug("executing http request", slog.Any("method", req.Method), slog.Any("url", rawURL)) r, err := http.DefaultClient.Do(req) if err != nil { return internal.ReturnError(logger, "could not retreive note", err) } + if r.StatusCode >= 300 { + return internal.ReturnError(logger, "could not retreive note", fmt.Errorf("status code %d", r.StatusCode)) + } + logger.Debug("decoding body") body, err := io.ReadAll(r.Body) if err != nil { diff --git a/src/go.mod b/src/go.mod index cb3f6c5..92236c0 100644 --- a/src/go.mod +++ b/src/go.mod @@ -1,15 +1,15 @@ module git.riou.xyz/jriou/coller -go 1.24 +go 1.24.0 -toolchain go1.24.6 +toolchain go1.24.7 require ( github.com/gorilla/mux v1.8.1 github.com/prometheus/client_golang v1.23.0 golang.design/x/clipboard v0.7.1 - golang.org/x/crypto v0.41.0 - golang.org/x/term v0.34.0 + golang.org/x/crypto v0.42.0 + golang.org/x/term v0.35.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.30.0 @@ -33,8 +33,8 @@ require ( golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect golang.org/x/image v0.28.0 // indirect golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect google.golang.org/protobuf v1.36.6 // indirect ) diff --git a/src/go.sum b/src/go.sum index 1d47cb0..455f4bf 100644 --- a/src/go.sum +++ b/src/go.sum @@ -52,6 +52,8 @@ golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c= golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE= golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= @@ -60,12 +62,20 @@ golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRN golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/src/server/config.go b/src/server/config.go index ea5e5d2..ba9529c 100644 --- a/src/server/config.go +++ b/src/server/config.go @@ -14,6 +14,8 @@ type Config struct { EncryptionKeyLength int `json:"encryption_key_length"` AllowClientEncryptionKey bool `json:"allow_client_encryption_key"` AllowNoEncryption bool `json:"allow_no_encryption"` + EnablePasswordProtection bool `json:"enable_password_protection"` + EnableUploadFileButton bool `json:"enable_upload_file_button"` ExpirationInterval int `json:"expiration_interval"` ListenAddress string `json:"listen_address"` ListenPort int `json:"listen_port"` @@ -27,7 +29,6 @@ type Config struct { ObservationInterval int `json:"observation_internal"` Languages []string `json:"languages"` Language string `json:"language"` - EnableUploadFileButton bool `json:"enable_upload_file_button"` TLSCertFile string `json:"tls_cert_file"` TLSKeyFile string `json:"tls_key_file"` BootstrapDirectory string `json:"bootstrap_directory"` @@ -75,8 +76,9 @@ func NewConfig() *Config { "SQL", "YAML", }, - Language: "text", - EnableUploadFileButton: true, + Language: "text", + EnableUploadFileButton: true, + EnablePasswordProtection: true, } } diff --git a/src/server/db.go b/src/server/db.go index 58982c9..3c29251 100644 --- a/src/server/db.go +++ b/src/server/db.go @@ -8,6 +8,7 @@ import ( "time" "github.com/bwmarrin/snowflake" + "golang.org/x/crypto/bcrypt" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -122,7 +123,7 @@ func (d *Database) Get(id string) (*Note, error) { return nil, nil } -func (d *Database) Create(content []byte, encryptionKey string, encrypted bool, expiration int, deleteAfterRead bool, language string) (note *Note, err error) { +func (d *Database) Create(content []byte, password string, encryptionKey string, encrypted bool, expiration int, deleteAfterRead bool, language string) (note *Note, err error) { if expiration == 0 { expiration = d.expiration } @@ -148,6 +149,7 @@ func (d *Database) Create(content []byte, encryptionKey string, encrypted bool, DeleteAfterRead: deleteAfterRead, Language: language, } + if encryptionKey != "" { if err = internal.ValidateEncryptionKey(encryptionKey); err != nil { return nil, err @@ -158,12 +160,22 @@ func (d *Database) Create(content []byte, encryptionKey string, encrypted bool, } note.Encrypted = true } + + if password != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + note.PasswordHash = hash + } + trx := d.db.Create(note) defer trx.Commit() if trx.Error != nil { d.logger.Warn("could not create note", slog.Any("error", trx.Error)) return nil, trx.Error } + return note, nil } diff --git a/src/server/handlers_api.go b/src/server/handlers_api.go index 51852c3..833d8ea 100644 --- a/src/server/handlers_api.go +++ b/src/server/handlers_api.go @@ -7,6 +7,7 @@ import ( "net/http" "github.com/gorilla/mux" + "golang.org/x/crypto/bcrypt" "git.riou.xyz/jriou/coller/internal" ) @@ -25,6 +26,7 @@ type CreateNoteHandler struct { type CreateNotePayload struct { Content string `json:"content"` + Password string `json:"password"` EncryptionKey string `json:"encryption_key"` Encrypted bool `json:"encrypted"` Expiration int `json:"expiration"` @@ -66,7 +68,7 @@ func (h *CreateNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - note, err := h.db.Create(content, body.EncryptionKey, body.Encrypted, body.Expiration, body.DeleteAfterRead, body.Language) + note, err := h.db.Create(content, body.Password, body.EncryptionKey, body.Encrypted, body.Expiration, body.DeleteAfterRead, body.Language) if err != nil { WriteError(w, "could not create note", err) return @@ -92,6 +94,10 @@ func (h *GetNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { WriteError(w, "could not get note", err) } else if note == nil { w.WriteHeader(http.StatusNotFound) + h.logger.Error("note does not exists", slog.Any("note_id", id)) + } else if note.PasswordHash != nil { + w.WriteHeader(http.StatusBadRequest) + h.logger.Error("note is password protected", slog.Any("note_id", note.ID)) } else { if note.Encrypted { w.Header().Set("Content-Type", "application/octet-stream") @@ -101,17 +107,32 @@ func (h *GetNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -type GetEncryptedNoteHandler struct { - logger *slog.Logger - db *Database +type GetProtectedNoteHandler struct { + logger *slog.Logger + db *Database + maxUploadSize int64 } -func (h *GetEncryptedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +type GetProtectedNotePayload struct { + EncryptionKey string `json:"encryption_key"` + Password string `json:"password"` +} + +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"] - encryptionKey := vars["encryptionKey"] + + bodyReader := http.MaxBytesReader(w, r.Body, h.maxUploadSize) + defer r.Body.Close() + + var body GetProtectedNotePayload + err := json.NewDecoder(bodyReader).Decode(&body) + if err != nil { + WriteError(w, "could not decode payload to read protected note", err) + return + } note, err := h.db.Get(id) @@ -123,14 +144,22 @@ func (h *GetEncryptedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque return } - if encryptionKey != "" && note.Encrypted { - note.Content, err = internal.Decrypt(note.Content, encryptionKey) + if body.EncryptionKey != "" && note.Encrypted { + note.Content, err = internal.Decrypt(note.Content, body.EncryptionKey) if err != nil { WriteError(w, "could not decrypt note", err) return } } + if body.Password != "" && (note.PasswordHash != nil || len(note.PasswordHash) > 0) { + err := bcrypt.CompareHashAndPassword(note.PasswordHash, []byte(body.Password)) + if err != nil { + WriteError(w, "could not validate password", err) + return + } + } + w.WriteHeader(http.StatusOK) fmt.Fprint(w, string(note.Content)) } diff --git a/src/server/handlers_web.go b/src/server/handlers_web.go index 813929a..c7a6087 100644 --- a/src/server/handlers_web.go +++ b/src/server/handlers_web.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/gorilla/mux" + "golang.org/x/crypto/bcrypt" "git.riou.xyz/jriou/coller/internal" ) @@ -25,6 +26,7 @@ type PageData struct { Err error URL string Note *Note + EnablePasswordProtection bool EnableUploadFileButton bool AllowClientEncryptionKey bool AllowNoEncryption bool @@ -111,6 +113,7 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req } h.logger.Debug("checking inputs") + password := r.FormValue("password") noEncryption := r.FormValue("no-encryption") encryptionKey := r.FormValue("encryption-key") expiration := r.FormValue("expiration") @@ -141,7 +144,7 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req } h.logger.Debug("saving note to the database") - note, err := h.db.Create(content, encryptionKey, encryptionKey != "", expirationInt, deleteAfterRead != "", language) + note, err := h.db.Create(content, password, encryptionKey, encryptionKey != "", expirationInt, deleteAfterRead != "", language) if err != nil { h.PageData.Err = err h.Templates.ExecuteTemplate(w, templateName, h.PageData) @@ -160,19 +163,19 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req } h.logger.Debug("rendering page") - h.Templates.ExecuteTemplate(w, "create", h.PageData) + h.Templates.ExecuteTemplate(w, templateName, h.PageData) } -type GetWebNoteHandler struct { +type GetRawWebNoteHandler struct { Templates *template.Template PageData PageData logger *slog.Logger db *Database } -func (h *GetWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (h *GetRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.PageData.Err = nil - templateName := "note" + templateName := "unprotectedNote" vars := mux.Vars(r) id := vars["id"] @@ -180,7 +183,7 @@ func (h *GetWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { note, err := h.db.Get(id) if err != nil { - h.PageData.Err = fmt.Errorf("could not find note: %v", err) + h.PageData.Err = fmt.Errorf("could not get raw note") h.Templates.ExecuteTemplate(w, templateName, h.PageData) return } @@ -199,13 +202,88 @@ func (h *GetWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.PageData.Note = note - h.logger.Debug("rendering note web page") - h.Templates.ExecuteTemplate(w, "note", h.PageData) + h.logger.Debug("rendering page") + + if len(note.PasswordHash) > 0 { + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, string(note.Content)) } -func (h *GetEncryptedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +type GetProtectedRawWebNoteHandler struct { + Templates *template.Template + PageData PageData + logger *slog.Logger + db *Database + maxUploadSize int64 +} + +func (h *GetProtectedRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.PageData.Err = nil - templateName := "note" + templateName := "protectedNote" + + vars := mux.Vars(r) + id := vars["id"] + + 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 + } + + password := r.FormValue("password") + + note, err := h.db.Get(id) + + if err != nil { + h.PageData.Err = fmt.Errorf("could not get raw note") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if note == nil { + h.PageData.Err = fmt.Errorf("note doesn't exist or has been deleted") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if note.Encrypted { + h.PageData.Err = fmt.Errorf("note is encrypted") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if err := bcrypt.CompareHashAndPassword(note.PasswordHash, []byte(password)); err != nil { + h.PageData.Err = fmt.Errorf("invalid password") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + h.PageData.Note = note + + h.logger.Debug("rendering page") + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, string(note.Content)) +} + +type GetEncryptedRawWebNoteHandler struct { + Templates *template.Template + PageData PageData + logger *slog.Logger + db *Database +} + +func (h *GetEncryptedRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.PageData.Err = nil + templateName := "unprotectedNote" vars := mux.Vars(r) id := vars["id"] @@ -214,7 +292,7 @@ func (h *GetEncryptedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Re note, err := h.db.Get(id) if err != nil { - h.PageData.Err = fmt.Errorf("could not find note: %v", err) + h.PageData.Err = fmt.Errorf("could not get raw note") h.Templates.ExecuteTemplate(w, templateName, h.PageData) return } @@ -228,7 +306,220 @@ func (h *GetEncryptedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Re if encryptionKey != "" && note.Encrypted { note.Content, err = internal.Decrypt(note.Content, encryptionKey) if err != nil { - h.PageData.Err = fmt.Errorf("could not decrypt note: %v", err) + h.PageData.Err = fmt.Errorf("could not decrypt note") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + } + + h.PageData.Note = note + + h.logger.Debug("rendering page") + + if len(note.PasswordHash) > 0 { + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, string(note.Content)) +} + +type GetProtectedEncryptedRawWebNoteHandler struct { + Templates *template.Template + PageData PageData + logger *slog.Logger + db *Database + maxUploadSize int64 +} + +func (h *GetProtectedEncryptedRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.PageData.Err = nil + templateName := "protectedNote" + + vars := mux.Vars(r) + id := vars["id"] + encryptionKey := vars["encryptionKey"] + + 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 + } + + password := r.FormValue("password") + + note, err := h.db.Get(id) + + if err != nil { + h.PageData.Err = fmt.Errorf("could not get raw note") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if note == nil { + h.PageData.Err = fmt.Errorf("note doesn't exist or has been deleted") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if err := bcrypt.CompareHashAndPassword(note.PasswordHash, []byte(password)); err != nil { + h.PageData.Err = fmt.Errorf("invalid password") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if encryptionKey != "" && note.Encrypted { + note.Content, err = internal.Decrypt(note.Content, encryptionKey) + if err != nil { + h.PageData.Err = fmt.Errorf("could not decrypt note") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + } + + h.PageData.Note = note + + h.logger.Debug("rendering page") + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, string(note.Content)) +} + +type GetWebNoteHandler struct { + Templates *template.Template + PageData PageData + logger *slog.Logger + db *Database +} + +func (h *GetWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.PageData.Err = nil + templateName := "unprotectedNote" + + vars := mux.Vars(r) + id := vars["id"] + + note, err := h.db.Get(id) + + if err != nil { + h.PageData.Err = err + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if note == nil { + h.PageData.Err = fmt.Errorf("note doesn't exist or has been deleted") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if note.Encrypted { + h.PageData.Err = fmt.Errorf("note is encrypted") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + h.PageData.Note = note + + h.logger.Debug("rendering page") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) +} + +type GetProtectedWebNoteHandler struct { + Templates *template.Template + PageData PageData + logger *slog.Logger + db *Database + maxUploadSize int64 +} + +func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.PageData.Err = nil + templateName := "protectedNote" + + vars := mux.Vars(r) + id := vars["id"] + + 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 + } + + password := r.FormValue("password") + + note, err := h.db.Get(id) + + if err != nil { + h.PageData.Err = err + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if note == nil { + h.PageData.Err = fmt.Errorf("note doesn't exist or has been deleted") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if note.Encrypted { + h.PageData.Err = fmt.Errorf("note is encrypted") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if err := bcrypt.CompareHashAndPassword(note.PasswordHash, []byte(password)); err != nil { + h.PageData.Err = fmt.Errorf("invalid password") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + h.PageData.Note = note + + h.logger.Debug("rendering page") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) +} + +type GetEncryptedWebNoteHandler struct { + Templates *template.Template + PageData PageData + logger *slog.Logger + db *Database +} + +func (h *GetEncryptedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.PageData.Err = nil + templateName := "unprotectedNote" + + vars := mux.Vars(r) + id := vars["id"] + encryptionKey := vars["encryptionKey"] + + note, err := h.db.Get(id) + + if err != nil { + h.PageData.Err = err + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if note == nil { + h.PageData.Err = fmt.Errorf("note doesn't exist or has been deleted") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if encryptionKey != "" && note.Encrypted { + note.Content, err = internal.Decrypt(note.Content, encryptionKey) + if err != nil { + h.PageData.Err = fmt.Errorf("could not decrypt note") h.Templates.ExecuteTemplate(w, templateName, h.PageData) return } @@ -237,7 +528,74 @@ func (h *GetEncryptedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Re h.PageData.Note = note h.logger.Debug("rendering encrypted note web page") - h.Templates.ExecuteTemplate(w, "note", h.PageData) + h.Templates.ExecuteTemplate(w, templateName, h.PageData) +} + +type GetProtectedEncryptedWebNoteHandler struct { + Templates *template.Template + PageData PageData + logger *slog.Logger + db *Database + maxUploadSize int64 +} + +func (h *GetProtectedEncryptedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.PageData.Err = nil + templateName := "protectedNote" + + vars := mux.Vars(r) + id := vars["id"] + encryptionKey := vars["encryptionKey"] + + 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 + } + + password := r.FormValue("password") + + note, err := h.db.Get(id) + + if err != nil { + h.PageData.Err = err + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if note == nil { + h.PageData.Err = fmt.Errorf("note doesn't exist or has been deleted") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if err := bcrypt.CompareHashAndPassword(note.PasswordHash, []byte(password)); err != nil { + h.PageData.Err = fmt.Errorf("invalid password") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + + if encryptionKey != "" && note.Encrypted { + note.Content, err = internal.Decrypt(note.Content, encryptionKey) + if err != nil { + h.PageData.Err = fmt.Errorf("could not decrypt note") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + } + + h.PageData.Note = note + + h.logger.Debug("rendering encrypted note web page") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) +} + +type ClientsHandler struct { + Templates *template.Template + PageData PageData + logger *slog.Logger } func (h *ClientsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/src/server/note.go b/src/server/note.go index f6bf156..9464cf9 100644 --- a/src/server/note.go +++ b/src/server/note.go @@ -10,6 +10,7 @@ type Note struct { ID string `json:"id" gorm:"primaryKey"` Content []byte `json:"content" gorm:"not null"` Encrypted bool `json:"encrypted"` + PasswordHash []byte `json:"password_hash"` ExpiresAt time.Time `json:"expires_at" gorm:"index"` DeleteAfterRead bool `json:"delete_after_read"` Language string `json:"language"` diff --git a/src/server/server.go b/src/server/server.go index 87b8aec..431a620 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -71,20 +71,7 @@ func WriteError(w http.ResponseWriter, message string, err error) { }.ToJSON()) } -type GetEncryptedWebNoteHandler struct { - Templates *template.Template - PageData PageData - logger *slog.Logger - db *Database -} - -type ClientsHandler struct { - Templates *template.Template - PageData PageData - logger *slog.Logger -} - -//go:embed templates/* +//go:embed templates/*.html var templatesFS embed.FS func (s *Server) Start() error { @@ -108,17 +95,18 @@ func (s *Server) Start() error { } r.Path("/api/note").Handler(createNoteHandler).Methods("POST") - getEncryptedNoteHandler := &GetEncryptedNoteHandler{ - logger: s.logger, - db: s.db, + getProtectedNoteHandler := &GetProtectedNoteHandler{ + logger: s.logger, + db: s.db, + maxUploadSize: s.config.MaxUploadSize, } - r.Path("/{id:[a-zA-Z0-9]+}/{encryptionKey:[a-zA-Z0-9]+}").Handler(getEncryptedNoteHandler).Methods("GET") + r.Path("/api/note/{id:[a-zA-Z0-9]+}").Handler(getProtectedNoteHandler).Methods("POST") getNoteHandler := &GetNoteHandler{ logger: s.logger, db: s.db, } - r.Path("/{id:[a-zA-Z0-9]+}").Handler(getNoteHandler).Methods("GET") + r.Path("/api/note/{id:[a-zA-Z0-9]+}").Handler(getNoteHandler).Methods("GET") // Web pages funcs := template.FuncMap{ @@ -134,6 +122,7 @@ func (s *Server) Start() error { Languages: s.config.Languages, BootstrapDirectory: s.config.BootstrapDirectory, EnableUploadFileButton: s.config.EnableUploadFileButton, + EnablePasswordProtection: s.config.EnablePasswordProtection, AllowClientEncryptionKey: s.config.AllowClientEncryptionKey, AllowNoEncryption: s.config.AllowNoEncryption, } @@ -177,6 +166,48 @@ func (s *Server) Start() error { } r.Path("/{id:[a-zA-Z0-9]+}/{encryptionKey:[a-zA-Z0-9]+}.html").Handler(encryptedWebNoteHandler).Methods("GET") + protectedEncryptedWebNoteHandler := &GetProtectedEncryptedWebNoteHandler{ + Templates: templates, + PageData: p, + logger: s.logger, + db: s.db, + maxUploadSize: s.config.MaxUploadSize, + } + r.Path("/{id:[a-zA-Z0-9]+}/{encryptionKey:[a-zA-Z0-9]+}.html").Handler(protectedEncryptedWebNoteHandler).Methods("POST") + + encryptedRawWebNoteHandler := &GetEncryptedRawWebNoteHandler{ + Templates: templates, + PageData: p, + logger: s.logger, + db: s.db, + } + r.Path("/{id:[a-zA-Z0-9]+}/{encryptionKey:[a-zA-Z0-9]+}/raw").Handler(encryptedRawWebNoteHandler).Methods("GET") + + protectedEncryptedRawWebNoteHandler := &GetProtectedEncryptedRawWebNoteHandler{ + Templates: templates, + PageData: p, + logger: s.logger, + db: s.db, + } + r.Path("/{id:[a-zA-Z0-9]+}/{encryptionKey:[a-zA-Z0-9]+}/raw").Handler(protectedEncryptedRawWebNoteHandler).Methods("POST") + + rawWebNoteHandler := &GetRawWebNoteHandler{ + Templates: templates, + PageData: p, + logger: s.logger, + db: s.db, + } + r.Path("/{id:[a-zA-Z0-9]+}/raw").Handler(rawWebNoteHandler).Methods("GET") + + protectedRawWebNoteHandler := &GetProtectedRawWebNoteHandler{ + Templates: templates, + PageData: p, + logger: s.logger, + db: s.db, + maxUploadSize: s.config.MaxUploadSize, + } + r.Path("/{id:[a-zA-Z0-9]+}/raw").Handler(protectedRawWebNoteHandler).Methods("POST") + webNoteHandler := &GetWebNoteHandler{ Templates: templates, PageData: p, @@ -185,6 +216,15 @@ func (s *Server) Start() error { } r.Path("/{id:[a-zA-Z0-9]+}.html").Handler(webNoteHandler).Methods("GET") + protectedWebNoteHandler := &GetProtectedWebNoteHandler{ + Templates: templates, + PageData: p, + logger: s.logger, + db: s.db, + maxUploadSize: s.config.MaxUploadSize, + } + r.Path("/{id:[a-zA-Z0-9]+}.html").Handler(protectedWebNoteHandler).Methods("POST") + if s.config.BootstrapDirectory != "" { r.PathPrefix("/static/bootstrap/").Handler(http.StripPrefix("/static/bootstrap/", http.FileServer(http.Dir(s.config.BootstrapDirectory)))) } diff --git a/src/server/templates/error.html b/src/server/templates/error.html new file mode 100644 index 0000000..6a48b44 --- /dev/null +++ b/src/server/templates/error.html @@ -0,0 +1,8 @@ +{{define "error"}} +
+ +
+{{end}} \ No newline at end of file diff --git a/src/server/templates/index.html b/src/server/templates/index.html index a6363c1..b4b3c00 100644 --- a/src/server/templates/index.html +++ b/src/server/templates/index.html @@ -13,6 +13,14 @@
+ {{if .EnablePasswordProtection}} +
+ +
+
+ +
+ {{end}} {{if .AllowClientEncryptionKey}}
@@ -25,9 +33,9 @@ {{end}} {{if .AllowNoEncryption}}
- - + +
{{end}}
diff --git a/src/server/templates/note.html b/src/server/templates/note.html index 09f7b6a..344f38f 100644 --- a/src/server/templates/note.html +++ b/src/server/templates/note.html @@ -1,69 +1,51 @@ {{define "note"}} - - - -{{block "head" .}}{{end}} - - - {{block "header" .}}{{end}} - - {{if ne .Err nil}} -
-