From 2c3ca08dbfdd6b8a3ae5e8fe4cd220f6db3312d8 Mon Sep 17 00:00:00 2001 From: Julien Riou Date: Sat, 20 Sep 2025 08:37:16 +0200 Subject: [PATCH 01/25] feat: Use snowflake identifiers Fixes #29. Signed-off-by: Julien Riou --- src/cmd/coller/main.go | 3 ++- src/cmd/collerd/README.md | 2 +- src/cmd/collerd/main.go | 4 ++-- src/go.mod | 1 + src/go.sum | 2 ++ src/server/config.go | 8 ++++---- src/server/db.go | 12 +++++++++++- src/server/note.go | 27 +-------------------------- src/server/server.go | 4 ---- 9 files changed, 24 insertions(+), 39 deletions(-) diff --git a/src/cmd/coller/main.go b/src/cmd/coller/main.go index dc75eab..67a7e59 100644 --- a/src/cmd/coller/main.go +++ b/src/cmd/coller/main.go @@ -13,9 +13,10 @@ import ( "path/filepath" "syscall" - "git.riou.xyz/jriou/coller/internal" "golang.design/x/clipboard" "golang.org/x/term" + + "git.riou.xyz/jriou/coller/internal" ) var ( diff --git a/src/cmd/collerd/README.md b/src/cmd/collerd/README.md index 5f9a06d..b85139a 100644 --- a/src/cmd/collerd/README.md +++ b/src/cmd/collerd/README.md @@ -18,7 +18,7 @@ The file format is **JSON**: * **title** (string): Title of the website * **database_type** (string): Type of the database (default "sqlite", "postgres" also supported) * **database_dsn** (string): Connection string for the database (default "collerd.db") -* **id_length** (int): Number of characters for note identifiers (default 5) +* **node_id** (int): Number between 0 and 1023 to define the node generating identifiers (see [snowflake](https://github.com/bwmarrin/snowflake)) * **password_length** (int): Number of characters for generated passwords (default 16) * **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") diff --git a/src/cmd/collerd/main.go b/src/cmd/collerd/main.go index 35ca6a2..4e45b1f 100644 --- a/src/cmd/collerd/main.go +++ b/src/cmd/collerd/main.go @@ -5,9 +5,10 @@ import ( "log/slog" "os" + "github.com/prometheus/client_golang/prometheus" + "git.riou.xyz/jriou/coller/internal" "git.riou.xyz/jriou/coller/server" - "github.com/prometheus/client_golang/prometheus" ) var ( @@ -69,7 +70,6 @@ func handleMain() int { return internal.ReturnError(logger, "could not create server", err) } - srv.SetIDLength(config.IDLength) srv.SetPasswordLength(config.PasswordLength) if config.EnableMetrics { diff --git a/src/go.mod b/src/go.mod index 35d8e39..cb3f6c5 100644 --- a/src/go.mod +++ b/src/go.mod @@ -17,6 +17,7 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect + github.com/bwmarrin/snowflake v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/src/go.sum b/src/go.sum index 2f772f9..1d47cb0 100644 --- a/src/go.sum +++ b/src/go.sum @@ -1,5 +1,7 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= +github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/src/server/config.go b/src/server/config.go index f07c9a7..97983f9 100644 --- a/src/server/config.go +++ b/src/server/config.go @@ -10,7 +10,7 @@ type Config struct { Title string `json:"title"` DatabaseType string `json:"database_type"` DatabaseDsn string `json:"database_dsn"` - IDLength int `json:"id_length"` + NodeID int64 `json:"node_id"` PasswordLength int `json:"password_length"` ExpirationInterval int `json:"expiration_interval"` ListenAddress string `json:"listen_address"` @@ -36,7 +36,7 @@ func NewConfig() *Config { Title: "Coller", DatabaseType: "sqlite", DatabaseDsn: "collerd.db", - IDLength: 5, + NodeID: 1, PasswordLength: 16, ExpirationInterval: 60, // 1 minute ListenAddress: "0.0.0.0", @@ -88,8 +88,8 @@ func (c *Config) Check() error { } } - if c.IDLength <= 0 { - return fmt.Errorf("identifiers length must be greater than zero") + if c.NodeID < 0 || c.NodeID > 1023 { + return fmt.Errorf("node id must be between 0 and 1023") } if c.PasswordLength < internal.MIN_PASSWORD_LENGTH || c.PasswordLength > internal.MAX_PASSWORD_LENGTH { diff --git a/src/server/db.go b/src/server/db.go index 0186b72..e158618 100644 --- a/src/server/db.go +++ b/src/server/db.go @@ -7,11 +7,13 @@ import ( "strings" "time" - "git.riou.xyz/jriou/coller/internal" + "github.com/bwmarrin/snowflake" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" + + "git.riou.xyz/jriou/coller/internal" ) type Database struct { @@ -22,6 +24,7 @@ type Database struct { expiration int languages []string language string + node *snowflake.Node } var gconfig = &gorm.Config{ @@ -48,6 +51,11 @@ func NewDatabase(logger *slog.Logger, config *Config) (d *Database, err error) { logger.Debug("connected to the database") + node, err := snowflake.NewNode(config.NodeID) + if err != nil { + return nil, err + } + d = &Database{ logger: l, db: db, @@ -56,6 +64,7 @@ func NewDatabase(logger *slog.Logger, config *Config) (d *Database, err error) { expiration: config.Expiration, languages: internal.ToLowerStringSlice(config.Languages), language: strings.ToLower(config.Language), + node: node, } if err = d.UpdateSchema(); err != nil { @@ -132,6 +141,7 @@ func (d *Database) Create(content []byte, password string, encrypted bool, expir } note = &Note{ + ID: d.node.Generate().String(), Content: content, ExpiresAt: time.Now().Add(time.Duration(expiration) * time.Second), Encrypted: encrypted, diff --git a/src/server/note.go b/src/server/note.go index bff6b73..f6bf156 100644 --- a/src/server/note.go +++ b/src/server/note.go @@ -1,17 +1,11 @@ package server import ( - "fmt" "time" - "git.riou.xyz/jriou/coller/internal" "gorm.io/gorm" ) -const ID_MAX_RETRIES = 10 - -var idLength = 5 - type Note struct { ID string `json:"id" gorm:"primaryKey"` Content []byte `json:"content" gorm:"not null"` @@ -21,27 +15,8 @@ type Note struct { Language string `json:"language"` } -// Generate ID and compress content before saving to the database +// Compress content before saving to the database func (n *Note) BeforeCreate(trx *gorm.DB) (err error) { - for i := 0; i < ID_MAX_RETRIES; i++ { - if n.ID != "" { - continue - } - - id := internal.GenerateChars(idLength) - - var note Note - trx.Where("id = ?", id).Find(¬e) - - if note.ID == "" { - n.ID = id - continue - } - } - if n.ID == "" { - return fmt.Errorf("could not find unique id before creating the note") - } - n.Content = Compress(n.Content) return nil } diff --git a/src/server/server.go b/src/server/server.go index 0c9bae5..f64e2cc 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -41,10 +41,6 @@ func NewServer(logger *slog.Logger, db *Database, config *Config, version string }, nil } -func (s *Server) SetIDLength(length int) { - idLength = length -} - func (s *Server) SetPasswordLength(length int) { passwordLength = length } From 634326190c36f7cd5699444e487ac4da51523bb7 Mon Sep 17 00:00:00 2001 From: Julien Riou Date: Mon, 22 Sep 2025 17:11:54 +0200 Subject: [PATCH 02/25] feat: add expiration time in the note web view Fixes #35. Signed-off-by: Julien Riou --- src/internal/utils.go | 10 ++++++++++ src/server/server.go | 1 + src/server/templates/note.html | 7 ++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/internal/utils.go b/src/internal/utils.go index 9db5ece..a1aa6fc 100644 --- a/src/internal/utils.go +++ b/src/internal/utils.go @@ -9,6 +9,7 @@ import ( "path/filepath" "regexp" "strings" + "time" ) func ReadConfig(file string, config interface{}) error { @@ -102,6 +103,15 @@ func HumanDuration(i int) string { return fmt.Sprintf("%d %s", i, w) } +// TimeDiff to return the number of seconds between this time and now +func TimeDiff(ts time.Time) int { + diff := int(time.Since(ts).Seconds()) + if diff < 0 { + return diff * -1 + } + return diff +} + func ReturnError(logger *slog.Logger, message string, err error) int { if err != nil { logger.Error(message, slog.Any("error", err)) diff --git a/src/server/server.go b/src/server/server.go index f64e2cc..9284308 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -106,6 +106,7 @@ func (s *Server) Start() error { // Web pages funcs := template.FuncMap{ "HumanDuration": internal.HumanDuration, + "TimeDiff": internal.TimeDiff, "lower": strings.ToLower, "string": func(b []byte) string { return string(b) }, } diff --git a/src/server/templates/note.html b/src/server/templates/note.html index 2570851..09f7b6a 100644 --- a/src/server/templates/note.html +++ b/src/server/templates/note.html @@ -19,13 +19,18 @@
Note {{.Note.ID}}
From 8e1dd686d3f49cc6f58fb122c31747e3b517fdb4 Mon Sep 17 00:00:00 2001 From: Julien Riou Date: Wed, 24 Sep 2025 07:09:01 +0200 Subject: [PATCH 03/25] feat: Rename password by encryption key Signed-off-by: Julien Riou --- src/cmd/coller/README.md | 10 +++---- src/cmd/coller/main.go | 46 ++++++++++++++++----------------- src/cmd/collerd/README.md | 10 +++---- src/cmd/collerd/main.go | 2 +- src/cmd/copier/README.md | 4 +-- src/cmd/copier/main.go | 21 ++++++++------- src/internal/encryption.go | 18 ++++++------- src/internal/encryption_test.go | 16 ++++++------ src/internal/internal.go | 8 +++--- src/internal/utils.go | 10 +++---- src/server/config.go | 22 ++++++++-------- src/server/db.go | 8 +++--- src/server/handlers_api.go | 10 +++---- src/server/handlers_web.go | 22 ++++++++-------- src/server/server.go | 16 ++++++------ src/server/templates/index.html | 12 ++++----- 16 files changed, 118 insertions(+), 117 deletions(-) diff --git a/src/cmd/coller/README.md b/src/cmd/coller/README.md index 1cf57a6..1e3d178 100644 --- a/src/cmd/coller/README.md +++ b/src/cmd/coller/README.md @@ -22,17 +22,17 @@ Create from file: coller -file filename.txt ``` -Provide password for encryption: +Provide encryption key: ``` -coller -ask-password -coller -password PASSWORD +coller -ask-encryption-key +coller -encryption-key ENCRYPTION_KEY ``` -Create public note: +Create a note in cleartext: ``` -coller -no-password +coller -no-encryption ``` Return the copier command to use client-side decryption instead of the URL: diff --git a/src/cmd/coller/main.go b/src/cmd/coller/main.go index 67a7e59..445ea14 100644 --- a/src/cmd/coller/main.go +++ b/src/cmd/coller/main.go @@ -64,10 +64,10 @@ func handleMain() int { configFile := flag.String("config", filepath.Join(homeDir, ".config", AppName+".json"), "Configuration file") reconfigure := flag.Bool("reconfigure", false, "Re-create configuration file") url := flag.String("url", "", "URL of the coller API") - password := flag.String("password", os.Getenv("COLLER_PASSWORD"), "Password to encrypt the note") - askPassword := flag.Bool("ask-password", false, "Read password from input") - noPassword := flag.Bool("no-password", false, "Allow notes without password") - passwordLength := flag.Int("password-length", 16, "Length of the auto-generated password") + encryptionKey := flag.String("encryption-key", os.Getenv("COLLER_ENCRYPTION_KEY"), "Key to encrypt the note") + askEncryptionKey := flag.Bool("ask-encryption-key", false, "Read encryption key from input") + noEncryption := flag.Bool("no-encryption", false, "Allow notes without encryption key") + encryptionKeyLength := flag.Int("encryption-key-length", 16, "Length of the auto-generated encryption key") flag.StringVar(&fileName, "file", "", "Read content of the note from a file") expiration := flag.Int("expiration", 0, "Number of seconds before expiration") deleteAfterRead := flag.Bool("delete-after-read", false, "Delete the note after the first read") @@ -140,22 +140,22 @@ func handleMain() int { content = clipboard.Read(clipboard.FmtText) } - if *askPassword { - fmt.Print("Password: ") + if *askEncryptionKey { + fmt.Print("Encryption key: ") p, err := term.ReadPassword(int(syscall.Stdin)) if err != nil { - return internal.ReturnError(logger, "could not read password", err) + return internal.ReturnError(logger, "could not read encryption key", err) } - *password = string(p) + *encryptionKey = string(p) fmt.Print("\n") } - if !*noPassword && *password == "" { - logger.Debug("generating random password") - if *passwordLength < internal.MIN_PASSWORD_LENGTH || *passwordLength > internal.MAX_PASSWORD_LENGTH { - return internal.ReturnError(logger, "invalid password length for auto-generated password", fmt.Errorf("password length must be between %d and %d", internal.MIN_PASSWORD_LENGTH, internal.MAX_PASSWORD_LENGTH)) + if !*noEncryption && *encryptionKey == "" { + logger.Debug("generating random encryption key") + if *encryptionKeyLength < internal.MIN_ENCRYPTION_KEY_LENGTH || *encryptionKeyLength > internal.MAX_ENCRYPTION_KEY_LENGTH { + return internal.ReturnError(logger, "invalid length of auto-generated encryption key", fmt.Errorf("encryption key length must be between %d and %d", internal.MIN_ENCRYPTION_KEY_LENGTH, internal.MAX_ENCRYPTION_KEY_LENGTH)) } - *password = internal.GenerateChars(*passwordLength) + *encryptionKey = internal.GenerateChars(*encryptionKeyLength) } if len(content) == 0 { @@ -173,13 +173,13 @@ func handleMain() int { p.Language = *language } - if *password != "" { - logger.Debug("validating password") - if err = internal.ValidatePassword(*password); err != nil { - return internal.ReturnError(logger, "invalid password", nil) + if *encryptionKey != "" { + logger.Debug("validating encryption key") + if err = internal.ValidateEncryptionKey(*encryptionKey); err != nil { + return internal.ReturnError(logger, "invalid encryption key", nil) } logger.Debug("encrypting content") - content, err = internal.Encrypt(content, *password) + content, err = internal.Encrypt(content, *encryptionKey) if err != nil { return internal.ReturnError(logger, "could not encrypt note", err) } @@ -242,21 +242,21 @@ func handleMain() int { logger.Debug("finding note location") var location string noteURL := *url + "/" + jsonBody.ID - if *password != "" { + if *encryptionKey != "" { if *copier { - location = fmt.Sprintf("copier -password %s %s", *password, noteURL) + location = fmt.Sprintf("copier -encryption-key %s %s", *encryptionKey, noteURL) } else { if *html { - location = fmt.Sprintf("%s/%s.html", noteURL, *password) + location = fmt.Sprintf("%s/%s.html", noteURL, *encryptionKey) } else { - location = fmt.Sprintf("%s/%s", noteURL, *password) + location = fmt.Sprintf("%s/%s", noteURL, *encryptionKey) } } } else { if *html { location = fmt.Sprintf("%s.html", noteURL) } else { - location = fmt.Sprintf("%s", noteURL) + location = noteURL } } diff --git a/src/cmd/collerd/README.md b/src/cmd/collerd/README.md index b85139a..ca8674d 100644 --- a/src/cmd/collerd/README.md +++ b/src/cmd/collerd/README.md @@ -19,7 +19,7 @@ The file format is **JSON**: * **database_type** (string): Type of the database (default "sqlite", "postgres" also supported) * **database_dsn** (string): Connection string for the database (default "collerd.db") * **node_id** (int): Number between 0 and 1023 to define the node generating identifiers (see [snowflake](https://github.com/bwmarrin/snowflake)) -* **password_length** (int): Number of characters for generated passwords (default 16) +* **encryption_key_length** (int): Number of characters for generated encryption key (default 16) * **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) @@ -52,7 +52,7 @@ Create a note. Body (JSON): * **content** (string): base64 encoded content (required) -* **password** (string): use server-side encryption with this password +* **encryption_key** (string): use server-side encryption with this encryption key * **encrypted** (bool): true if the content has been encrypted by the client * **expiration** (int): lifetime of the note in seconds (must be supported by the server) * **delete_after_read** (bool): delete the note after the first read @@ -62,12 +62,12 @@ Response (JSON): * **id** (string): ID of the note -### GET /\/\ +### GET /\/\ > [!WARNING] -> Potential password leak +> Potential encryption key leak -Return content of a note encrypted by the given password. +Return content of a note encrypted by the given encryption key. ### GET /\ diff --git a/src/cmd/collerd/main.go b/src/cmd/collerd/main.go index 4e45b1f..265fe73 100644 --- a/src/cmd/collerd/main.go +++ b/src/cmd/collerd/main.go @@ -70,7 +70,7 @@ func handleMain() int { return internal.ReturnError(logger, "could not create server", err) } - srv.SetPasswordLength(config.PasswordLength) + srv.SetEncryptionKeyLength(config.EncryptionKeyLength) if config.EnableMetrics { reg := prometheus.NewRegistry() diff --git a/src/cmd/copier/README.md b/src/cmd/copier/README.md index 81a7a88..ed268e0 100644 --- a/src/cmd/copier/README.md +++ b/src/cmd/copier/README.md @@ -11,6 +11,6 @@ copier -help # Examples ``` -copier -password PASSWORD URL -copier -ask-password URL +copier -encryption-key ENCRYPTION_KEY URL +copier -ask-encryption-key URL ``` \ No newline at end of file diff --git a/src/cmd/copier/main.go b/src/cmd/copier/main.go index 73c18d3..5924936 100644 --- a/src/cmd/copier/main.go +++ b/src/cmd/copier/main.go @@ -9,8 +9,9 @@ import ( "os" "syscall" - "git.riou.xyz/jriou/coller/internal" "golang.org/x/term" + + "git.riou.xyz/jriou/coller/internal" ) var ( @@ -28,8 +29,8 @@ func handleMain() int { quiet := flag.Bool("quiet", false, "Log errors only") verbose := flag.Bool("verbose", false, "Print more logs") debug := flag.Bool("debug", false, "Print even more logs") - password := flag.String("password", os.Getenv("COLLER_PASSWORD"), "Password to decrypt the note") - askPassword := flag.Bool("ask-password", false, "Read password from input") + encryptionKey := flag.String("encryption-key", os.Getenv("COLLER_ENCRYPTION_KEY"), "Key to decrypt the note") + askEncryptionKey := flag.Bool("ask-encryption-key", false, "Read encryption key from input") 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") @@ -60,13 +61,13 @@ func handleMain() int { } logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) - if *askPassword { - fmt.Print("Password: ") + if *askEncryptionKey { + fmt.Print("Encryption key: ") p, err := term.ReadPassword(int(syscall.Stdin)) if err != nil { - return internal.ReturnError(logger, "could not read password", err) + return internal.ReturnError(logger, "could not read encryption key", err) } - *password = string(p) + *encryptionKey = string(p) fmt.Print("\n") } @@ -102,11 +103,11 @@ func handleMain() int { } var content []byte - if *password != "" { + if *encryptionKey != "" { logger.Debug("decrypting note") - content, err = internal.Decrypt(body, *password) + content, err = internal.Decrypt(body, *encryptionKey) if err != nil { - return internal.ReturnError(logger, "could not decrypt paste", err) + return internal.ReturnError(logger, "could not decrypt note", err) } } else { content = body diff --git a/src/internal/encryption.go b/src/internal/encryption.go index be605bf..af8446b 100644 --- a/src/internal/encryption.go +++ b/src/internal/encryption.go @@ -19,21 +19,21 @@ const ( // NewCipher creates a cipher using XChaCha20-Poly1305 // https://pkg.go.dev/golang.org/x/crypto/chacha20poly1305 -// A salt is required to derive the key from a password using argon -func NewCipher(password string, salt []byte) (cipher.AEAD, error) { - key := argon2.IDKey([]byte(password), salt, KeyTime, KeyMemory, KeyThreads, KeySize) +// A salt is required to derive the key from an encryption key using argon +func NewCipher(encryptionKey string, salt []byte) (cipher.AEAD, error) { + key := argon2.IDKey([]byte(encryptionKey), salt, KeyTime, KeyMemory, KeyThreads, KeySize) return chacha20poly1305.NewX(key) } -// Encrypt to encrypt a plaintext with a password +// Encrypt to encrypt a plaintext with an encryption key // Returns a byte slice with the generated salt, nonce and the ciphertext -func Encrypt(plaintext []byte, password string) (result []byte, err error) { +func Encrypt(plaintext []byte, encryptionKey string) (result []byte, err error) { salt := make([]byte, SaltSize) if n, err := rand.Read(salt); err != nil || n != SaltSize { return nil, err } - aead, err := NewCipher(password, salt) + aead, err := NewCipher(encryptionKey, salt) if err != nil { return nil, err } @@ -53,15 +53,15 @@ func Encrypt(plaintext []byte, password string) (result []byte, err error) { return result, nil } -// Decrypt to decrypt a ciphertext with a password +// Decrypt to decrypt a ciphertext with a encryption key // Returns the plaintext -func Decrypt(ciphertext []byte, password string) ([]byte, error) { +func Decrypt(ciphertext []byte, encryptionKey string) ([]byte, error) { if len(ciphertext) < SaltSize { return nil, fmt.Errorf("ciphertext is too short: cannot read salt") } salt := ciphertext[:SaltSize] - aead, err := NewCipher(password, salt) + aead, err := NewCipher(encryptionKey, salt) if err != nil { return nil, err } diff --git a/src/internal/encryption_test.go b/src/internal/encryption_test.go index 14b5f6a..6ff44d4 100644 --- a/src/internal/encryption_test.go +++ b/src/internal/encryption_test.go @@ -6,10 +6,10 @@ import ( func TestEncryptAndDecrypt(t *testing.T) { plaintext := "test" - password := "test" - wrongPassword := password + "wrong" + encryptionKey := "test" + wrongEncryptionKey := encryptionKey + "wrong" - ciphertext, err := Encrypt([]byte(plaintext), password) + ciphertext, err := Encrypt([]byte(plaintext), encryptionKey) if err != nil { t.Errorf("unexpected error when encrypting: %v", err) return @@ -20,7 +20,7 @@ func TestEncryptAndDecrypt(t *testing.T) { return } - cleartext, err := Decrypt(ciphertext, password) + cleartext, err := Decrypt(ciphertext, encryptionKey) if err != nil { t.Errorf("unexpected error when decrypting: %v", err) return @@ -31,14 +31,14 @@ func TestEncryptAndDecrypt(t *testing.T) { return } - if password == wrongPassword { - t.Errorf("passwords must be different") + if encryptionKey == wrongEncryptionKey { + t.Errorf("encryption keys must be different") return } - _, err = Decrypt(ciphertext, wrongPassword) + _, err = Decrypt(ciphertext, wrongEncryptionKey) if err == nil { - t.Errorf("expected error when decrypting with a wrong password, got none") + t.Errorf("expected error when decrypting with a wrong encryption key, got none") return } } diff --git a/src/internal/internal.go b/src/internal/internal.go index 2780ae9..9141158 100644 --- a/src/internal/internal.go +++ b/src/internal/internal.go @@ -1,8 +1,8 @@ package internal const ( - RC_OK = 0 - RC_ERROR = 1 - MIN_PASSWORD_LENGTH = 16 - MAX_PASSWORD_LENGTH = 256 + RC_OK = 0 + RC_ERROR = 1 + MIN_ENCRYPTION_KEY_LENGTH = 16 + MAX_ENCRYPTION_KEY_LENGTH = 256 ) diff --git a/src/internal/utils.go b/src/internal/utils.go index a1aa6fc..601f001 100644 --- a/src/internal/utils.go +++ b/src/internal/utils.go @@ -58,13 +58,13 @@ func GenerateChars(n int) string { return string(b) } -// Passwords must be URL compatible and strong enough +// Encryption key must be URL compatible and strong enough // Requiring only alphanumeric chars with a size between 16 and 256 -var passwordRegexp = regexp.MustCompile("^[a-zA-Z0-9]{16,256}$") +var encryptionKeyRegexp = regexp.MustCompile("^[a-zA-Z0-9]{16,256}$") -func ValidatePassword(p string) error { - if !passwordRegexp.MatchString(p) { - return fmt.Errorf("password doesn't match '%s'", passwordRegexp) +func ValidateEncryptionKey(p string) error { + if !encryptionKeyRegexp.MatchString(p) { + return fmt.Errorf("encryption key doesn't match '%s'", encryptionKeyRegexp) } return nil } diff --git a/src/server/config.go b/src/server/config.go index 97983f9..f2a6a6b 100644 --- a/src/server/config.go +++ b/src/server/config.go @@ -11,7 +11,7 @@ type Config struct { DatabaseType string `json:"database_type"` DatabaseDsn string `json:"database_dsn"` NodeID int64 `json:"node_id"` - PasswordLength int `json:"password_length"` + EncryptionKeyLength int `json:"encryption_key_length"` ExpirationInterval int `json:"expiration_interval"` ListenAddress string `json:"listen_address"` ListenPort int `json:"listen_port"` @@ -33,14 +33,14 @@ type Config struct { func NewConfig() *Config { return &Config{ - Title: "Coller", - DatabaseType: "sqlite", - DatabaseDsn: "collerd.db", - NodeID: 1, - PasswordLength: 16, - ExpirationInterval: 60, // 1 minute - ListenAddress: "0.0.0.0", - ListenPort: 8080, + Title: "Coller", + DatabaseType: "sqlite", + DatabaseDsn: "collerd.db", + NodeID: 1, + EncryptionKeyLength: 16, + ExpirationInterval: 60, // 1 minute + ListenAddress: "0.0.0.0", + ListenPort: 8080, Expirations: []int{ 300, // 5 minutes 3600, // 1 hour @@ -92,8 +92,8 @@ func (c *Config) Check() error { return fmt.Errorf("node id must be between 0 and 1023") } - if c.PasswordLength < internal.MIN_PASSWORD_LENGTH || c.PasswordLength > internal.MAX_PASSWORD_LENGTH { - return fmt.Errorf("password length must be between %d and %d", internal.MIN_PASSWORD_LENGTH, internal.MAX_PASSWORD_LENGTH) + if c.EncryptionKeyLength < internal.MIN_ENCRYPTION_KEY_LENGTH || c.EncryptionKeyLength > internal.MAX_ENCRYPTION_KEY_LENGTH { + return fmt.Errorf("encryption key length must be between %d and %d", internal.MIN_ENCRYPTION_KEY_LENGTH, internal.MAX_ENCRYPTION_KEY_LENGTH) } return nil } diff --git a/src/server/db.go b/src/server/db.go index e158618..58982c9 100644 --- a/src/server/db.go +++ b/src/server/db.go @@ -122,7 +122,7 @@ func (d *Database) Get(id string) (*Note, error) { return nil, nil } -func (d *Database) Create(content []byte, password string, encrypted bool, expiration int, deleteAfterRead bool, language string) (note *Note, err error) { +func (d *Database) Create(content []byte, encryptionKey string, encrypted bool, expiration int, deleteAfterRead bool, language string) (note *Note, err error) { if expiration == 0 { expiration = d.expiration } @@ -148,11 +148,11 @@ func (d *Database) Create(content []byte, password string, encrypted bool, expir DeleteAfterRead: deleteAfterRead, Language: language, } - if password != "" { - if err = internal.ValidatePassword(password); err != nil { + if encryptionKey != "" { + if err = internal.ValidateEncryptionKey(encryptionKey); err != nil { return nil, err } - note.Content, err = internal.Encrypt(note.Content, password) + note.Content, err = internal.Encrypt(note.Content, encryptionKey) if err != nil { return nil, err } diff --git a/src/server/handlers_api.go b/src/server/handlers_api.go index f260328..feab64e 100644 --- a/src/server/handlers_api.go +++ b/src/server/handlers_api.go @@ -23,7 +23,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"` DeleteAfterRead bool `json:"delete_after_read"` @@ -54,7 +54,7 @@ func (h *CreateNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - note, err := h.db.Create(content, body.Password, body.Encrypted, body.Expiration, body.DeleteAfterRead, body.Language) + note, err := h.db.Create(content, body.EncryptionKey, body.Encrypted, body.Expiration, body.DeleteAfterRead, body.Language) if err != nil { WriteError(w, "could not create note", err) return @@ -99,7 +99,7 @@ func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque vars := mux.Vars(r) id := vars["id"] - password := vars["password"] + encryptionKey := vars["encryptionKey"] note, err := h.db.Get(id) @@ -111,8 +111,8 @@ func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque return } - if password != "" && note.Encrypted { - note.Content, err = internal.Decrypt(note.Content, password) + if encryptionKey != "" && note.Encrypted { + note.Content, err = internal.Decrypt(note.Content, encryptionKey) if err != nil { WriteError(w, "could not decrypt note", err) return diff --git a/src/server/handlers_web.go b/src/server/handlers_web.go index d059d7a..d5e92ad 100644 --- a/src/server/handlers_web.go +++ b/src/server/handlers_web.go @@ -109,15 +109,15 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req } h.logger.Debug("checking inputs") - noPassword := r.FormValue("no-password") - password := r.FormValue("password") + noEncryption := r.FormValue("no-encryption") + encryptionKey := r.FormValue("encryption-key") expiration := r.FormValue("expiration") deleteAfterRead := r.FormValue("delete-after-read") language := r.FormValue("language") - if password == "" && noPassword == "" { - h.logger.Debug("generating password") - password = internal.GenerateChars(passwordLength) + if encryptionKey == "" && noEncryption == "" { + h.logger.Debug("generating encryption key") + encryptionKey = internal.GenerateChars(encryptionKeyLength) } h.logger.Debug("computing expiration") @@ -129,7 +129,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, password, password != "", expirationInt, deleteAfterRead != "", language) + note, err := h.db.Create(content, encryptionKey, encryptionKey != "", expirationInt, deleteAfterRead != "", language) if err != nil { h.PageData.Err = err h.Templates.ExecuteTemplate(w, templateName, h.PageData) @@ -143,8 +143,8 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req } h.PageData.URL = fmt.Sprintf("%s%s/%s", scheme, r.Host, note.ID) - if password != "" { - h.PageData.URL += "/" + password + if encryptionKey != "" { + h.PageData.URL += "/" + encryptionKey } h.logger.Debug("rendering page") @@ -197,7 +197,7 @@ func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Re vars := mux.Vars(r) id := vars["id"] - password := vars["password"] + encryptionKey := vars["encryptionKey"] note, err := h.db.Get(id) @@ -213,8 +213,8 @@ func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Re return } - if password != "" && note.Encrypted { - note.Content, err = internal.Decrypt(note.Content, password) + 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.Templates.ExecuteTemplate(w, templateName, h.PageData) diff --git a/src/server/server.go b/src/server/server.go index 9284308..b8b3778 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -16,10 +16,10 @@ import ( ) var ( - passwordLength = internal.MIN_PASSWORD_LENGTH - supportedOSes = []string{"linux", "darwin"} - supportedArches = []string{"amd64", "arm64"} - supportedClients = []string{"coller", "copier"} + encryptionKeyLength = internal.MIN_ENCRYPTION_KEY_LENGTH + supportedOSes = []string{"linux", "darwin"} + supportedArches = []string{"amd64", "arm64"} + supportedClients = []string{"coller", "copier"} ) type Server struct { @@ -41,8 +41,8 @@ func NewServer(logger *slog.Logger, db *Database, config *Config, version string }, nil } -func (s *Server) SetPasswordLength(length int) { - passwordLength = length +func (s *Server) SetEncryptionKeyLength(length int) { + encryptionKeyLength = length } func (s *Server) SetMetrics(metrics *Metrics) { @@ -100,7 +100,7 @@ func (s *Server) Start() error { // API 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]+}/{encryptionKey:[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 @@ -150,7 +150,7 @@ func (s *Server) Start() error { logger: s.logger, db: s.db, } - r.Path("/{id:[a-zA-Z0-9]+}/{password:[a-zA-Z0-9]+}.html").Handler(protectedWebNoteHandler).Methods("GET") + r.Path("/{id:[a-zA-Z0-9]+}/{encryptionKey:[a-zA-Z0-9]+}.html").Handler(protectedWebNoteHandler).Methods("GET") webNoteHandler := &GetWebNoteHandler{ Templates: templates, diff --git a/src/server/templates/index.html b/src/server/templates/index.html index 4c18a78..f7319e9 100644 --- a/src/server/templates/index.html +++ b/src/server/templates/index.html @@ -14,17 +14,17 @@
- +
+ title="Letters and numbers with length from 16 to 256" class="form-control" id="encryption-key" + name="encryption-key">
- - + +
Date: Wed, 24 Sep 2025 09:50:13 +0200 Subject: [PATCH 04/25] refactor: Rename protected to encrypted Signed-off-by: Julien Riou --- src/server/handlers_api.go | 4 ++-- src/server/handlers_web.go | 4 ++-- src/server/server.go | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/server/handlers_api.go b/src/server/handlers_api.go index feab64e..47bc512 100644 --- a/src/server/handlers_api.go +++ b/src/server/handlers_api.go @@ -89,12 +89,12 @@ func (h *GetNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -type GetProtectedNoteHandler struct { +type GetEncryptedNoteHandler struct { logger *slog.Logger db *Database } -func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (h *GetEncryptedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") vars := mux.Vars(r) diff --git a/src/server/handlers_web.go b/src/server/handlers_web.go index d5e92ad..896169e 100644 --- a/src/server/handlers_web.go +++ b/src/server/handlers_web.go @@ -191,7 +191,7 @@ func (h *GetWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.Templates.ExecuteTemplate(w, "note", h.PageData) } -func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (h *GetEncryptedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.PageData.Err = nil templateName := "note" @@ -224,7 +224,7 @@ func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Re h.PageData.Note = note - h.logger.Debug("rendering protected note web page") + h.logger.Debug("rendering encrypted note web page") h.Templates.ExecuteTemplate(w, "note", h.PageData) } diff --git a/src/server/server.go b/src/server/server.go index b8b3778..85e494c 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -71,7 +71,7 @@ func WriteError(w http.ResponseWriter, message string, err error) { }.ToJSON()) } -type GetProtectedWebNoteHandler struct { +type GetEncryptedWebNoteHandler struct { Templates *template.Template PageData PageData logger *slog.Logger @@ -100,7 +100,7 @@ func (s *Server) Start() error { // API 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]+}/{encryptionKey:[a-zA-Z0-9]+}").Handler(&GetProtectedNoteHandler{logger: s.logger, db: s.db}).Methods("GET") + r.Path("/{id:[a-zA-Z0-9]+}/{encryptionKey:[a-zA-Z0-9]+}").Handler(&GetEncryptedNoteHandler{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 @@ -144,13 +144,13 @@ func (s *Server) Start() error { r.Path("/clients.html").Handler(clientsHandler).Methods("GET") r.Path("/clients/{os:[a-z]+}-{arch:[a-z0-9]+}/{clientName:[a-z]+}").Handler(&ClientHandler{logger: s.logger, version: p.Version}).Methods("GET") - protectedWebNoteHandler := &GetProtectedWebNoteHandler{ + encryptedWebNoteHandler := &GetEncryptedWebNoteHandler{ Templates: templates, PageData: p, logger: s.logger, db: s.db, } - r.Path("/{id:[a-zA-Z0-9]+}/{encryptionKey:[a-zA-Z0-9]+}.html").Handler(protectedWebNoteHandler).Methods("GET") + r.Path("/{id:[a-zA-Z0-9]+}/{encryptionKey:[a-zA-Z0-9]+}.html").Handler(encryptedWebNoteHandler).Methods("GET") webNoteHandler := &GetWebNoteHandler{ Templates: templates, From 61ca30690b824de58022ab55b195e6dfa51bf7f7 Mon Sep 17 00:00:00 2001 From: Julien Riou Date: Wed, 24 Sep 2025 17:44:50 +0200 Subject: [PATCH 05/25] feat: Disable levels of encryptions by default - Add `allow_client_encryption_key` option to allow encryption key provided by the client on the web UI (false by default) - Add `allow_no_encryption` option to allow notes without encryption (disabled by default) Signed-off-by: Julien Riou --- src/cmd/collerd/README.md | 2 ++ src/server/config.go | 46 +++++++++++++++++---------------- src/server/handlers_api.go | 18 ++++++++++--- src/server/handlers_web.go | 34 ++++++++++++++++-------- src/server/server.go | 45 +++++++++++++++++++++++++------- src/server/templates/index.html | 7 ++++- 6 files changed, 105 insertions(+), 47 deletions(-) diff --git a/src/cmd/collerd/README.md b/src/cmd/collerd/README.md index ca8674d..4ad8b1e 100644 --- a/src/cmd/collerd/README.md +++ b/src/cmd/collerd/README.md @@ -20,6 +20,8 @@ The file format is **JSON**: * **database_dsn** (string): Connection string for the database (default "collerd.db") * **node_id** (int): Number between 0 and 1023 to define the node generating identifiers (see [snowflake](https://github.com/bwmarrin/snowflake)) * **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 * **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) diff --git a/src/server/config.go b/src/server/config.go index f2a6a6b..ea5e5d2 100644 --- a/src/server/config.go +++ b/src/server/config.go @@ -7,28 +7,30 @@ import ( ) type Config struct { - Title string `json:"title"` - DatabaseType string `json:"database_type"` - DatabaseDsn string `json:"database_dsn"` - NodeID int64 `json:"node_id"` - EncryptionKeyLength int `json:"encryption_key_length"` - ExpirationInterval int `json:"expiration_interval"` - ListenAddress string `json:"listen_address"` - ListenPort int `json:"listen_port"` - Expirations []int `json:"expirations"` - Expiration int `json:"expiration"` - MaxUploadSize int64 `json:"max_upload_size"` - ShowVersion bool `json:"show_version"` - EnableMetrics bool `json:"enable_metrics"` - PrometheusRoute string `json:"prometheus_route"` - PrometheusNotesMetric string `json:"prometheus_notes_metric"` - 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"` + Title string `json:"title"` + DatabaseType string `json:"database_type"` + DatabaseDsn string `json:"database_dsn"` + NodeID int64 `json:"node_id"` + EncryptionKeyLength int `json:"encryption_key_length"` + AllowClientEncryptionKey bool `json:"allow_client_encryption_key"` + AllowNoEncryption bool `json:"allow_no_encryption"` + ExpirationInterval int `json:"expiration_interval"` + ListenAddress string `json:"listen_address"` + ListenPort int `json:"listen_port"` + Expirations []int `json:"expirations"` + Expiration int `json:"expiration"` + MaxUploadSize int64 `json:"max_upload_size"` + ShowVersion bool `json:"show_version"` + EnableMetrics bool `json:"enable_metrics"` + PrometheusRoute string `json:"prometheus_route"` + PrometheusNotesMetric string `json:"prometheus_notes_metric"` + 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"` } func NewConfig() *Config { diff --git a/src/server/handlers_api.go b/src/server/handlers_api.go index 47bc512..51852c3 100644 --- a/src/server/handlers_api.go +++ b/src/server/handlers_api.go @@ -16,9 +16,11 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) { } type CreateNoteHandler struct { - logger *slog.Logger - db *Database - maxUploadSize int64 + logger *slog.Logger + db *Database + maxUploadSize int64 + allowClientEncryptionKey bool + allowNoEncryption bool } type CreateNotePayload struct { @@ -47,6 +49,16 @@ func (h *CreateNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if !h.allowNoEncryption && !body.Encrypted { + WriteError(w, "could not create note", fmt.Errorf("encryption is mandatory")) + return + } + + if !h.allowClientEncryptionKey && body.EncryptionKey != "" { + WriteError(w, "could not create note", fmt.Errorf("client encryption key is not allowed")) + return + } + content, err := internal.Decode(body.Content) if err != nil { diff --git a/src/server/handlers_web.go b/src/server/handlers_web.go index 896169e..813929a 100644 --- a/src/server/handlers_web.go +++ b/src/server/handlers_web.go @@ -17,16 +17,18 @@ import ( ) type PageData struct { - Title string - Version string - Expirations []int - Expiration int - Languages []string - Err error - URL string - Note *Note - EnableUploadFileButton bool - BootstrapDirectory string + Title string + Version string + Expirations []int + Expiration int + Languages []string + Err error + URL string + Note *Note + EnableUploadFileButton bool + AllowClientEncryptionKey bool + AllowNoEncryption bool + BootstrapDirectory string } type HomeHandler struct { @@ -115,7 +117,17 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req deleteAfterRead := r.FormValue("delete-after-read") language := r.FormValue("language") - if encryptionKey == "" && noEncryption == "" { + if !h.PageData.AllowNoEncryption && noEncryption != "" { + h.PageData.Err = fmt.Errorf("encryption is mandatory") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + } + + if !h.PageData.AllowClientEncryptionKey && encryptionKey != "" { + h.PageData.Err = fmt.Errorf("client encryption key is not allowed") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + } + + if !h.PageData.AllowClientEncryptionKey && encryptionKey == "" && noEncryption == "" { h.logger.Debug("generating encryption key") encryptionKey = internal.GenerateChars(encryptionKeyLength) } diff --git a/src/server/server.go b/src/server/server.go index 85e494c..87b8aec 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -99,9 +99,26 @@ func (s *Server) Start() error { } // API - 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]+}/{encryptionKey:[a-zA-Z0-9]+}").Handler(&GetEncryptedNoteHandler{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") + createNoteHandler := &CreateNoteHandler{ + logger: s.logger, + db: s.db, + maxUploadSize: s.config.MaxUploadSize, + allowClientEncryptionKey: s.config.AllowClientEncryptionKey, + allowNoEncryption: s.config.AllowNoEncryption, + } + r.Path("/api/note").Handler(createNoteHandler).Methods("POST") + + getEncryptedNoteHandler := &GetEncryptedNoteHandler{ + logger: s.logger, + db: s.db, + } + r.Path("/{id:[a-zA-Z0-9]+}/{encryptionKey:[a-zA-Z0-9]+}").Handler(getEncryptedNoteHandler).Methods("GET") + + getNoteHandler := &GetNoteHandler{ + logger: s.logger, + db: s.db, + } + r.Path("/{id:[a-zA-Z0-9]+}").Handler(getNoteHandler).Methods("GET") // Web pages funcs := template.FuncMap{ @@ -111,22 +128,25 @@ func (s *Server) Start() error { "string": func(b []byte) string { return string(b) }, } p := PageData{ - Title: s.config.Title, - Expirations: s.config.Expirations, - Expiration: s.config.Expiration, - Languages: s.config.Languages, - BootstrapDirectory: s.config.BootstrapDirectory, + Title: s.config.Title, + Expirations: s.config.Expirations, + Expiration: s.config.Expiration, + Languages: s.config.Languages, + BootstrapDirectory: s.config.BootstrapDirectory, + EnableUploadFileButton: s.config.EnableUploadFileButton, + AllowClientEncryptionKey: s.config.AllowClientEncryptionKey, + AllowNoEncryption: s.config.AllowNoEncryption, } if s.config.ShowVersion { p.Version = s.version } - p.EnableUploadFileButton = s.config.EnableUploadFileButton templates, err := template.New("templates").Funcs(funcs).ParseFS(templatesFS, "templates/*.html") if err != nil { return err } + createNoteWithFormHandler := &CreateNoteWithFormHandler{ Templates: templates, PageData: p, @@ -142,7 +162,12 @@ func (s *Server) Start() error { logger: s.logger, } r.Path("/clients.html").Handler(clientsHandler).Methods("GET") - r.Path("/clients/{os:[a-z]+}-{arch:[a-z0-9]+}/{clientName:[a-z]+}").Handler(&ClientHandler{logger: s.logger, version: p.Version}).Methods("GET") + + clientHandler := &ClientHandler{ + logger: s.logger, + version: p.Version, + } + r.Path("/clients/{os:[a-z]+}-{arch:[a-z0-9]+}/{clientName:[a-z]+}").Handler(clientHandler).Methods("GET") encryptedWebNoteHandler := &GetEncryptedWebNoteHandler{ Templates: templates, diff --git a/src/server/templates/index.html b/src/server/templates/index.html index f7319e9..a6363c1 100644 --- a/src/server/templates/index.html +++ b/src/server/templates/index.html @@ -13,6 +13,7 @@
+ {{if .AllowClientEncryptionKey}}
@@ -21,11 +22,14 @@ title="Letters and numbers with length from 16 to 256" class="form-control" id="encryption-key" name="encryption-key">
+ {{end}} + {{if .AllowNoEncryption}}
+ {{end}}
@@ -40,7 +44,8 @@
From 9e0254c0b51e69bd9d890ed88d09f7d0e223c35e Mon Sep 17 00:00:00 2001 From: Julien Riou Date: Sat, 27 Sep 2025 08:35:26 +0200 Subject: [PATCH 06/25] 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}} -
-
@@ -69,11 +70,15 @@
+ {{if .DisableEditor}} + + {{else}}
+ style="min-height: 300px; resize: vertical; overflow: auto;">
+ {{end}}
@@ -82,38 +87,40 @@
- + {{if eq false .DisableEditor}} + + {{end}} {{block "footer" .}}{{end}} diff --git a/src/server/templates/note.html b/src/server/templates/note.html index 344f38f..977705c 100644 --- a/src/server/templates/note.html +++ b/src/server/templates/note.html @@ -20,32 +20,44 @@ {{end}}
+ {{if .DisableEditor}}
-
+
+{{string .Note.Content}}
+            
+
+ {{else}} +
+
- + + {{end}}
{{end}} {{end}} \ No newline at end of file From 1fcde736a8272aedb56f2afff7a3eff665dbb86e Mon Sep 17 00:00:00 2001 From: Julien Riou Date: Sat, 27 Sep 2025 10:05:58 +0200 Subject: [PATCH 08/25] fix: convert id to int64 Snowflake identifiers are integers, not strings. BREAKING CHANGE: notes that are not using snowflake identifiers will not be compatible anymore. Signed-off-by: Julien Riou --- src/cmd/coller/main.go | 4 ++-- src/cmd/collerd/README.md | 2 +- src/server/db.go | 6 +++--- src/server/handlers_api.go | 2 +- src/server/handlers_web.go | 2 +- src/server/note.go | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/cmd/coller/main.go b/src/cmd/coller/main.go index c300e0e..b710214 100644 --- a/src/cmd/coller/main.go +++ b/src/cmd/coller/main.go @@ -36,7 +36,7 @@ type NotePayload struct { } type NoteResponse struct { - ID string `json:"id"` + ID int64 `json:"id"` Message string `json:"message,omitempty"` Error string `json:"error,omitempty"` } @@ -246,7 +246,7 @@ func handleMain() int { logger.Debug("finding note location") var location string - noteURL := *url + "/" + jsonBody.ID + noteURL := *url + "/" + fmt.Sprintf("%d", jsonBody.ID) if *copier { location = "copier" if *encryptionKey != "" { diff --git a/src/cmd/collerd/README.md b/src/cmd/collerd/README.md index 1f62bab..e4defd7 100644 --- a/src/cmd/collerd/README.md +++ b/src/cmd/collerd/README.md @@ -64,7 +64,7 @@ Body (JSON): * **language** (string): language of the note (must be supported by the server) Response (JSON): -* **id** (string): ID of the note +* **id** (int): ID of the note ### GET /api/note/\/\ diff --git a/src/server/db.go b/src/server/db.go index 3c29251..f8c5e38 100644 --- a/src/server/db.go +++ b/src/server/db.go @@ -112,7 +112,7 @@ func (d *Database) Get(id string) (*Note, error) { d.logger.Warn("could not find note", slog.Any("error", trx.Error)) return nil, trx.Error } - if note.ID != "" { + if note.ID != 0 { if note.DeleteAfterRead { if err := d.Delete(note.ID); err != nil { return nil, err @@ -142,7 +142,7 @@ func (d *Database) Create(content []byte, password string, encryptionKey string, } note = &Note{ - ID: d.node.Generate().String(), + ID: d.node.Generate().Int64(), Content: content, ExpiresAt: time.Now().Add(time.Duration(expiration) * time.Second), Encrypted: encrypted, @@ -179,7 +179,7 @@ func (d *Database) Create(content []byte, password string, encryptionKey string, return note, nil } -func (d *Database) Delete(id string) error { +func (d *Database) Delete(id int64) error { trx := d.db.Where("id = ?", id).Delete(&Note{}) defer trx.Commit() if trx.Error != nil { diff --git a/src/server/handlers_api.go b/src/server/handlers_api.go index 833d8ea..4f03c5a 100644 --- a/src/server/handlers_api.go +++ b/src/server/handlers_api.go @@ -35,7 +35,7 @@ type CreateNotePayload struct { } type CreateNoteResponse struct { - ID string `json:"id"` + ID int64 `json:"id"` } func (h *CreateNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/src/server/handlers_web.go b/src/server/handlers_web.go index aa91e59..91bfe9a 100644 --- a/src/server/handlers_web.go +++ b/src/server/handlers_web.go @@ -160,7 +160,7 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req scheme = "https://" } - h.PageData.URL = fmt.Sprintf("%s%s/%s", scheme, r.Host, note.ID) + h.PageData.URL = fmt.Sprintf("%s%s/%d", scheme, r.Host, note.ID) if encryptionKey != "" { h.PageData.URL += "/" + encryptionKey } diff --git a/src/server/note.go b/src/server/note.go index 9464cf9..6f20e1a 100644 --- a/src/server/note.go +++ b/src/server/note.go @@ -7,7 +7,7 @@ import ( ) type Note struct { - ID string `json:"id" gorm:"primaryKey"` + ID int64 `json:"id" gorm:"primaryKey"` Content []byte `json:"content" gorm:"not null"` Encrypted bool `json:"encrypted"` PasswordHash []byte `json:"password_hash"` From ee7b5f0c6edf4f7a512aec15e413f465b4ff4f99 Mon Sep 17 00:00:00 2001 From: Julien Riou Date: Wed, 1 Oct 2025 12:40:46 +0200 Subject: [PATCH 09/25] feat: Pass encryption key in URL fragment - Remove encryptionKey from URL - Use POST method to pass both password and encryption key - Parse URL fragment to extract the encryption key from the web (using javascript) and from the CLI Fixes #36. Signed-off-by: Julien Riou --- src/cmd/coller/main.go | 14 +- src/cmd/copier/main.go | 5 + src/server/handlers_web.go | 273 +++------------------- src/server/server.go | 67 ++---- src/server/templates/create.html | 16 +- src/server/templates/note.html | 2 +- src/server/templates/protectedNote.html | 2 +- src/server/templates/unprotectedNote.html | 40 +++- 8 files changed, 103 insertions(+), 316 deletions(-) diff --git a/src/cmd/coller/main.go b/src/cmd/coller/main.go index b710214..ce138c9 100644 --- a/src/cmd/coller/main.go +++ b/src/cmd/coller/main.go @@ -249,22 +249,20 @@ func handleMain() int { noteURL := *url + "/" + fmt.Sprintf("%d", jsonBody.ID) if *copier { location = "copier" - if *encryptionKey != "" { - location += " -encryption-key " + *encryptionKey - } if *password != "" { location += " -password '" + *password + "'" } location += " " + noteURL + if *encryptionKey != "" { + location += "#" + *encryptionKey + } } else { location = noteURL - if *encryptionKey != "" { - location += "/" + *encryptionKey - } if *html { location += ".html" - } else { - location += "/raw" + } + if *encryptionKey != "" { + location += "#" + *encryptionKey } } diff --git a/src/cmd/copier/main.go b/src/cmd/copier/main.go index 867f852..0f9c062 100644 --- a/src/cmd/copier/main.go +++ b/src/cmd/copier/main.go @@ -96,6 +96,11 @@ func handleMain() int { } u.Path = "api/note" + u.Path + if u.Fragment != "" { + *encryptionKey = u.Fragment + u.Fragment = "" + } + rawURL = u.String() logger.Debug("creating http request") diff --git a/src/server/handlers_web.go b/src/server/handlers_web.go index 91bfe9a..55720c9 100644 --- a/src/server/handlers_web.go +++ b/src/server/handlers_web.go @@ -160,9 +160,9 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req scheme = "https://" } - h.PageData.URL = fmt.Sprintf("%s%s/%d", scheme, r.Host, note.ID) + h.PageData.URL = fmt.Sprintf("%s%s/%d.html", scheme, r.Host, note.ID) if encryptionKey != "" { - h.PageData.URL += "/" + encryptionKey + h.PageData.URL += "#" + encryptionKey } h.logger.Debug("rendering page") @@ -197,17 +197,11 @@ func (h *GetRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) 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") - if len(note.PasswordHash) > 0 { + if note.Encrypted || len(note.PasswordHash) > 0 { h.Templates.ExecuteTemplate(w, templateName, h.PageData) return } @@ -241,11 +235,12 @@ func (h *GetProtectedRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http } password := r.FormValue("password") + encryptionKey := r.FormValue("encryption-key") note, err := h.db.Get(id) if err != nil { - h.PageData.Err = fmt.Errorf("could not get raw note") + h.PageData.Err = fmt.Errorf("could not find note") h.Templates.ExecuteTemplate(w, templateName, h.PageData) return } @@ -257,56 +252,11 @@ func (h *GetProtectedRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http } 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"] - encryptionKey := vars["encryptionKey"] - - 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 encryptionKey != "" && note.Encrypted { + if encryptionKey == "" { + h.PageData.Err = fmt.Errorf("encryption key not found") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } note.Content, err = internal.Decrypt(note.Content, encryptionKey) if err != nil { h.PageData.Err = fmt.Errorf("could not decrypt note") @@ -315,70 +265,9 @@ func (h *GetEncryptedRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http } } - 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") + 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 } @@ -421,12 +310,6 @@ func (h *GetWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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") @@ -457,11 +340,12 @@ func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Re } password := r.FormValue("password") + encryptionKey := r.FormValue("encryption-key") note, err := h.db.Get(id) if err != nil { - h.PageData.Err = err + h.PageData.Err = fmt.Errorf("could not find note") h.Templates.ExecuteTemplate(w, templateName, h.PageData) return } @@ -473,15 +357,25 @@ func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Re } if note.Encrypted { - h.PageData.Err = fmt.Errorf("note is encrypted") - h.Templates.ExecuteTemplate(w, templateName, h.PageData) - return + if encryptionKey == "" { + h.PageData.Err = fmt.Errorf("encryption key not found") + h.Templates.ExecuteTemplate(w, templateName, h.PageData) + return + } + 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 + } } - 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 len(note.PasswordHash) > 0 { + 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 @@ -490,111 +384,6 @@ func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Re 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 - } - } - - h.PageData.Note = note - - h.logger.Debug("rendering encrypted note web page") - 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 diff --git a/src/server/server.go b/src/server/server.go index 025ffdf..dd6b6a0 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -161,56 +161,6 @@ func (s *Server) Start() error { } r.Path("/clients/{os:[a-z]+}-{arch:[a-z0-9]+}/{clientName:[a-z]+}").Handler(clientHandler).Methods("GET") - encryptedWebNoteHandler := &GetEncryptedWebNoteHandler{ - Templates: templates, - PageData: p, - logger: s.logger, - db: s.db, - } - 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, @@ -228,6 +178,23 @@ func (s *Server) Start() error { } r.Path("/{id:[a-zA-Z0-9]+}.html").Handler(protectedWebNoteHandler).Methods("POST") + rawWebNoteHandler := &GetRawWebNoteHandler{ + Templates: templates, + PageData: p, + logger: s.logger, + db: s.db, + } + r.Path("/{id:[a-zA-Z0-9]+}").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]+}").Handler(protectedRawWebNoteHandler).Methods("POST") + if s.config.AceDirectory != "" { r.PathPrefix("/static/ace-builds/").Handler(http.StripPrefix("/static/ace-builds/", http.FileServer(http.Dir(s.config.AceDirectory)))) } diff --git a/src/server/templates/create.html b/src/server/templates/create.html index 9970fa9..569ebbf 100644 --- a/src/server/templates/create.html +++ b/src/server/templates/create.html @@ -8,18 +8,18 @@ {{block "header" .}}{{end}}
- {{if eq .Err nil}} - - {{else}} + {{if .Err}} + {{else}} + {{end}}
diff --git a/src/server/templates/note.html b/src/server/templates/note.html index 977705c..7972a3e 100644 --- a/src/server/templates/note.html +++ b/src/server/templates/note.html @@ -8,7 +8,7 @@