diff --git a/VERSION b/VERSION index 9084fa2..3a3cd8c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 +1.3.1 diff --git a/package-lock.json b/package-lock.json index 4cf810c..65ad597 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "ace-builds": "^1.43.3", "bootstrap": "^5.3.8" } }, @@ -18,6 +19,11 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/ace-builds": { + "version": "1.43.3", + "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.3.tgz", + "integrity": "sha512-MCl9rALmXwIty/4Qboijo/yNysx1r6hBTzG+6n/TiOm5LFhZpEvEIcIITPFiEOEFDfgBOEmxu+a4f54LEFM6Sg==" + }, "node_modules/bootstrap": { "version": "5.3.8", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", diff --git a/package.json b/package.json index 719e5df..5374149 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "ace-builds": "^1.43.3", "bootstrap": "^5.3.8" } } 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 dc75eab..e213740 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 ( @@ -31,12 +32,12 @@ 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 { - ID string `json:"id"` + ID int64 `json:"id"` Message string `json:"message,omitempty"` - Error string `json:"error,omitempty"` } type Config struct { @@ -60,13 +61,14 @@ 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") + jsonFormat := flag.Bool("json", false, "Print logs in JSON format") 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") @@ -75,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() @@ -100,7 +103,13 @@ func handleMain() int { if *quiet { level = slog.LevelError } - logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) + + var logger *slog.Logger + if *jsonFormat { + logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})) + } else { + logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) + } if *url == "" { if _, err = os.Stat(*configFile); errors.Is(err, os.ErrNotExist) || *reconfigure { @@ -139,22 +148,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 { @@ -171,14 +180,17 @@ func handleMain() int { if *language != "" { p.Language = *language } - if *password != "" { - logger.Debug("validating password") - if err = internal.ValidatePassword(*password); err != nil { - return internal.ReturnError(logger, "invalid password", nil) + p.Password = *password + } + + 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) } @@ -186,8 +198,12 @@ func handleMain() int { } logger.Debug("encoding content") - encoded := internal.Encode(content) - p.Content = encoded + encodedContent := internal.Encode(content) + p.Content = encodedContent + + logger.Debug("encoding password") + encodedPassword := internal.Encode([]byte(*password)) + p.Password = encodedPassword payload, err := json.Marshal(p) if err != nil { @@ -235,27 +251,28 @@ func handleMain() int { } if r.StatusCode != http.StatusOK { - return internal.ReturnError(logger, jsonBody.Message, fmt.Errorf("%s", jsonBody.Error)) + return internal.ReturnError(logger, jsonBody.Message, nil) } logger.Debug("finding note location") var location string - noteURL := *url + "/" + jsonBody.ID - if *password != "" { - if *copier { - location = fmt.Sprintf("copier -password %s %s", *password, noteURL) - } else { - if *html { - location = fmt.Sprintf("%s/%s.html", noteURL, *password) - } else { - location = fmt.Sprintf("%s/%s", noteURL, *password) - } + noteURL := *url + "/" + fmt.Sprintf("%d", jsonBody.ID) + if *copier { + location = "copier" + if *password != "" { + location += " -password '" + *password + "'" + } + location += " " + noteURL + if *encryptionKey != "" { + location += "#" + *encryptionKey } } else { + location = noteURL if *html { - location = fmt.Sprintf("%s.html", noteURL) - } else { - location = fmt.Sprintf("%s", noteURL) + location += ".html" + } + if *encryptionKey != "" { + location += "#" + *encryptionKey } } diff --git a/src/cmd/collerd/README.md b/src/cmd/collerd/README.md index 5f9a06d..a8936e4 100644 --- a/src/cmd/collerd/README.md +++ b/src/cmd/collerd/README.md @@ -18,8 +18,12 @@ 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) -* **password_length** (int): Number of characters for generated passwords (default 16) +* **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 +* **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) @@ -31,14 +35,17 @@ The file format is **JSON**: * **prometheus_route** (string): Route to expose Prometheus metrics (default "/metrics") * **prometheus_notes_metric** (string): Name of the notes count metric (default "collerd_notes") * **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) +* **languages** ([]string): List of supported [languages](https://github.com/ajaxorg/ace/tree/master/src/mode) * **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 +* **ace_directory** (string): Serve [Ace](hhttps://ace.c9.io/) assets from this local directory (ex: "./node_modules/ace-builds"). See **Dependencies** for details. * **bootstrap_directory** (string): Serve [Bootstrap](https://getbootstrap.com/) assets from this local directory (ex: "./node_modules/bootstrap/dist"). See **Dependencies** for details. +* **clients_base_directory** (string): Serve clients binaries from this local base directory (ex: "./releases"). The version will be append to the directory. Ignored if `show_version` is disabled. +* **clients_base_url** (string): Define the base URL to download clients (default "https://git.riou.xyz/jriou/coller/releases/download"). The version (or "latest") will be append. +* **disable_editor** (bool): Disable Ace editor. -The configuration file is not required but the service might not be exposed to the public. +The configuration file is optional. ## API @@ -52,45 +59,63 @@ 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 * **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 /\/\ +### GET /api/note/\/\ > [!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 /\ +### POST /api/note/\/\ + +> [!WARNING] +> Potential encryption key leak + +Return content of a protected note encrypted by the given encryption key. + +Body (JSON): +* **password** (string): base64 encoded 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): base64 encoded password used to protect the note (required) + ### Errors Errors return **500 Server Internal Error** with the **JSON** payload: -* **message** (string): context of the error -* **error** (string): error message +* **message** (string): message of the error ## Dependencies The web interface depends on: +- [Ace](https://ace.c9.io/) - [Bootstrap](https://getbootstrap.com/) -- [Monaco Editor](https://github.com/microsoft/monaco-editor/) By default, those dependencies are fetched from **remote CDN** services by the client. -If you would like to download them to serve them locally: +If you would like to download and serve them locally: ``` npm install @@ -106,8 +131,7 @@ Then configure the local directories: ```json { + "ace_directory": "./node_modules/ace-builds", "bootstrap_directory": "./node_modules/bootstrap/dist" } -``` - -Downloading Monaco Editor is not supported yet. +``` \ No newline at end of file diff --git a/src/cmd/collerd/main.go b/src/cmd/collerd/main.go index 35ca6a2..2837dd4 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 ( @@ -26,6 +27,7 @@ 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") + jsonFormat := flag.Bool("json", false, "Print logs in JSON format") configFileName := flag.String("config", "", "Configuration file name") flag.Parse() @@ -45,7 +47,13 @@ func handleMain() int { if *quiet { level = slog.LevelError } - logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) + + var logger *slog.Logger + if *jsonFormat { + logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})) + } else { + logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) + } if *configFileName != "" { err = internal.ReadConfig(*configFileName, config) @@ -69,8 +77,7 @@ func handleMain() int { return internal.ReturnError(logger, "could not create server", err) } - srv.SetIDLength(config.IDLength) - 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..fc59e23 100644 --- a/src/cmd/copier/main.go +++ b/src/cmd/copier/main.go @@ -1,16 +1,20 @@ package main import ( + "bytes" + "encoding/json" "flag" "fmt" "io" "log/slog" "net/http" + "net/url" "os" "syscall" - "git.riou.xyz/jriou/coller/internal" "golang.org/x/term" + + "git.riou.xyz/jriou/coller/internal" ) var ( @@ -20,6 +24,15 @@ var ( GitCommit string ) +type NotePayload struct { + Password string `json:"password"` +} + +type NoteResponse struct { + ID int64 `json:"id"` + Message string `json:"message,omitempty"` +} + func handleMain() int { flag.Usage = usage @@ -28,11 +41,13 @@ 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") + jsonFormat := flag.Bool("json", false, "Print logs in JSON format") + 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") + password := flag.String("password", os.Getenv("COLLER_PASSWORD"), "Password to access the note") flag.Parse() @@ -46,7 +61,7 @@ func handleMain() int { return internal.RC_ERROR } - url := flag.Args()[0] + rawURL := flag.Args()[0] var level slog.Level if *debug { @@ -58,15 +73,21 @@ func handleMain() int { if *quiet { level = slog.LevelError } - logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) - if *askPassword { - fmt.Print("Password: ") + var logger *slog.Logger + if *jsonFormat { + logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})) + } else { + logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) + } + + 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") } @@ -80,16 +101,46 @@ 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 + + if u.Fragment != "" { + *encryptionKey = u.Fragment + u.Fragment = "" + } + + rawURL = u.String() + + logger.Debug("creating http request") + var req *http.Request + if *password != "" { + body := &NotePayload{ + Password: internal.Encode([]byte(*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) @@ -101,12 +152,21 @@ func handleMain() int { return internal.ReturnError(logger, "could not read response", err) } - var content []byte - if *password != "" { - logger.Debug("decrypting note") - content, err = internal.Decrypt(body, *password) + if r.StatusCode != http.StatusOK { + jsonBody := &NoteResponse{} + err = json.Unmarshal(body, jsonBody) if err != nil { - return internal.ReturnError(logger, "could not decrypt paste", err) + return internal.ReturnError(logger, "could not decode response", err) + } + return internal.ReturnError(logger, jsonBody.Message, nil) + } + + var content []byte + if *encryptionKey != "" { + logger.Debug("decrypting note") + content, err = internal.Decrypt(body, *encryptionKey) + if err != nil { + return internal.ReturnError(logger, "could not decrypt note", err) } } else { content = body diff --git a/src/go.mod b/src/go.mod index 35d8e39..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 @@ -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 @@ -32,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 2f772f9..455f4bf 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= @@ -50,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= @@ -58,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/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 9db5ece..601f001 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 { @@ -57,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 } @@ -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/config.go b/src/server/config.go index f07c9a7..9e41bd8 100644 --- a/src/server/config.go +++ b/src/server/config.go @@ -7,40 +7,47 @@ import ( ) type Config struct { - Title string `json:"title"` - DatabaseType string `json:"database_type"` - DatabaseDsn string `json:"database_dsn"` - IDLength int `json:"id_length"` - PasswordLength int `json:"password_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"` + 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"` + 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"` + TLSCertFile string `json:"tls_cert_file"` + TLSKeyFile string `json:"tls_key_file"` + AceDirectory string `json:"ace_directory"` + BootstrapDirectory string `json:"bootstrap_directory"` + DisableEditor bool `json:"disable_editor"` + ClientsBaseURL string `json:"clients_base_url"` + ClientsBaseDirectory string `json:"clients_base_directory"` } func NewConfig() *Config { return &Config{ - Title: "Coller", - DatabaseType: "sqlite", - DatabaseDsn: "collerd.db", - IDLength: 5, - 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 @@ -56,25 +63,26 @@ func NewConfig() *Config { PrometheusNotesMetric: "collerd_notes", ObservationInterval: 60, Languages: []string{ - "Text", - "CSS", - "Dockerfile", - "Go", - "HCL", - "HTML", - "Javascript", - "JSON", - "Markdown", - "Perl", - "Python", - "Ruby", - "Rust", - "Shell", - "SQL", - "YAML", + "css", + "dockerfile", + "golang", + "html", + "javascript", + "json", + "markdown", + "perl", + "python", + "ruby", + "rust", + "sh", + "sql", + "text", + "yaml", }, - Language: "text", - EnableUploadFileButton: true, + Language: "text", + EnableUploadFileButton: true, + EnablePasswordProtection: true, + ClientsBaseURL: "https://git.riou.xyz/jriou/coller/releases/download", } } @@ -88,12 +96,12 @@ 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 { - 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 0186b72..9f10855 100644 --- a/src/server/db.go +++ b/src/server/db.go @@ -7,11 +7,14 @@ import ( "strings" "time" - "git.riou.xyz/jriou/coller/internal" + "github.com/bwmarrin/snowflake" + "golang.org/x/crypto/bcrypt" "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 +25,7 @@ type Database struct { expiration int languages []string language string + node *snowflake.Node } var gconfig = &gorm.Config{ @@ -48,6 +52,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 +65,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 { @@ -102,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 @@ -113,7 +123,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, password []byte, encryptionKey string, encrypted bool, expiration int, deleteAfterRead bool, language string) (note *Note, err error) { if expiration == 0 { expiration = d.expiration } @@ -132,32 +142,44 @@ func (d *Database) Create(content []byte, password string, encrypted bool, expir } note = &Note{ + ID: d.node.Generate().Int64(), Content: content, ExpiresAt: time.Now().Add(time.Duration(expiration) * time.Second), Encrypted: encrypted, 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 } note.Encrypted = true } + + if len(password) > 0 { + hash, err := bcrypt.GenerateFromPassword(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 } -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/errors.go b/src/server/errors.go new file mode 100644 index 0000000..f685728 --- /dev/null +++ b/src/server/errors.go @@ -0,0 +1,26 @@ +package server + +import "errors" + +var ( + ErrCouldNotFindNote = errors.New("could not find note") + ErrNoteDoesNotExist = errors.New("note does not exist") + ErrCouldNotParseForm = errors.New("could not parse form") + ErrEncryptionKeyNotFound = errors.New("encryption key not found") + ErrCouldNotDecryptNote = errors.New("could not decrypt note") + ErrInvalidPassword = errors.New("invalid password") + ErrInvalidExpiration = errors.New("invalid expiration") + ErrInvalidLanguage = errors.New("invalid language") + ErrCouldNotParseFile = errors.New("could not parse file") + ErrFileTooLarge = errors.New("file too large") + ErrTextFileExpected = errors.New("text file expected") + ErrCouldNotReadFile = errors.New("could not read file") + ErrEmptyNote = errors.New("empty note") + ErrEncryptionRequired = errors.New("encryption is required") + ErrClientEncryptionKeyNotAllowed = errors.New("client encryption key is not allowed") + ErrCouldNotCreateNote = errors.New("could not create note") + ErrCouldNotDecodePayload = errors.New("could not decode payload") + ErrCouldNotDecodeContent = errors.New("could not decode content") + ErrCouldNotDecodePassword = errors.New("could not decode password") + ErrNoteIsPasswordProtected = errors.New("note is password protected") +) diff --git a/src/server/handlers_api.go b/src/server/handlers_api.go index f260328..56b2451 100644 --- a/src/server/handlers_api.go +++ b/src/server/handlers_api.go @@ -7,23 +7,71 @@ import ( "net/http" "github.com/gorilla/mux" + "golang.org/x/crypto/bcrypt" "git.riou.xyz/jriou/coller/internal" ) +type APIErrorResponse struct { + Message string `json:"message"` +} + +func (e APIErrorResponse) ToJSON() string { + b, err := json.Marshal(e) + if err == nil { + return string(b) + } + return fmt.Sprintf("{\"message\":\"could not serialize response to JSON\", \"error\":\"%v\"}", err) +} + +func apiError(level int, w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + // Wrap error for logging + if err != nil { + err = fmt.Errorf("%v: %w", topLevelErr, err) + } else { + err = topLevelErr + } + logger.Error(fmt.Sprintf("%v", err)) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(level) + fmt.Fprint(w, APIErrorResponse{ + Message: fmt.Sprintf("%v", topLevelErr), + }.ToJSON()) +} + +func APIError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + apiError(http.StatusInternalServerError, w, logger, topLevelErr, err) +} + +func APIErrorNotFound(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + apiError(http.StatusNotFound, w, logger, topLevelErr, err) +} + +func APIErrorBadRequest(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + apiError(http.StatusBadRequest, w, logger, topLevelErr, err) +} + func HealthHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "OK") } type CreateNoteHandler struct { - logger *slog.Logger - db *Database - maxUploadSize int64 + logger *slog.Logger + db *Database + maxUploadSize int64 + allowClientEncryptionKey bool + allowNoEncryption bool +} + +func (h *CreateNoteHandler) Name() string { + return "CreateNoteHandler" } type CreateNotePayload struct { Content string `json:"content"` Password string `json:"password"` + EncryptionKey string `json:"encryption_key"` Encrypted bool `json:"encrypted"` Expiration int `json:"expiration"` DeleteAfterRead bool `json:"delete_after_read"` @@ -31,32 +79,49 @@ type CreateNotePayload struct { } type CreateNoteResponse struct { - ID string `json:"id"` + ID int64 `json:"id"` } func (h *CreateNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + logger := h.logger.With("handler", h.Name()) + bodyReader := http.MaxBytesReader(w, r.Body, h.maxUploadSize) defer r.Body.Close() var body CreateNotePayload err := json.NewDecoder(bodyReader).Decode(&body) if err != nil { - WriteError(w, "could not decode payload to create note", err) + APIError(w, logger, ErrCouldNotDecodePayload, err) + return + } + + if !h.allowNoEncryption && !body.Encrypted { + APIError(w, logger, ErrEncryptionRequired, nil) + return + } + + if !h.allowClientEncryptionKey && body.EncryptionKey != "" { + APIError(w, logger, ErrClientEncryptionKeyNotAllowed, nil) return } content, err := internal.Decode(body.Content) - if err != nil { - WriteError(w, "could not decode content", err) + APIError(w, logger, ErrCouldNotDecodeContent, err) return } - note, err := h.db.Create(content, body.Password, body.Encrypted, body.Expiration, body.DeleteAfterRead, body.Language) + password, err := internal.Decode(body.Password) if err != nil { - WriteError(w, "could not create note", err) + APIError(w, logger, ErrCouldNotDecodePassword, err) + return + } + + note, err := h.db.Create(content, password, body.EncryptionKey, body.Encrypted, body.Expiration, body.DeleteAfterRead, body.Language) + if err != nil { + APIError(w, logger, ErrCouldNotCreateNote, err) return } @@ -69,6 +134,10 @@ type GetNoteHandler struct { db *Database } +func (h *GetNoteHandler) Name() string { + return "GetNoteHandler" +} + func (h *GetNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") @@ -76,10 +145,14 @@ func (h *GetNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { note, err := h.db.Get(id) + logger := h.logger.With("handler", h.Name(), "note_id", id) + if err != nil { - WriteError(w, "could not get note", err) + APIError(w, logger, ErrCouldNotFindNote, err) } else if note == nil { - w.WriteHeader(http.StatusNotFound) + APIErrorNotFound(w, logger, ErrNoteDoesNotExist, nil) + } else if note.PasswordHash != nil { + APIErrorBadRequest(w, logger, ErrNoteIsPasswordProtected, nil) } else { if note.Encrypted { w.Header().Set("Content-Type", "application/octet-stream") @@ -90,8 +163,18 @@ func (h *GetNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } type GetProtectedNoteHandler struct { - logger *slog.Logger - db *Database + logger *slog.Logger + db *Database + maxUploadSize int64 +} + +type GetProtectedNotePayload struct { + EncryptionKey string `json:"encryption_key"` + Password string `json:"password"` +} + +func (h *GetProtectedNoteHandler) Name() string { + return "GetProtectedNoteHandler" } func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -99,22 +182,47 @@ func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque vars := mux.Vars(r) id := vars["id"] - password := vars["password"] + + logger := h.logger.With("handler", h.Name(), "note_id", id) + + bodyReader := http.MaxBytesReader(w, r.Body, h.maxUploadSize) + defer r.Body.Close() + + var body GetProtectedNotePayload + err := json.NewDecoder(bodyReader).Decode(&body) + if err != nil { + APIError(w, logger, ErrCouldNotDecodePayload, err) + return + } note, err := h.db.Get(id) if err != nil { - WriteError(w, "could not get note", err) + APIError(w, logger, ErrCouldNotFindNote, err) return } else if note == nil { w.WriteHeader(http.StatusNotFound) return } - if password != "" && note.Encrypted { - note.Content, err = internal.Decrypt(note.Content, password) + if body.EncryptionKey != "" && note.Encrypted { + note.Content, err = internal.Decrypt(note.Content, body.EncryptionKey) if err != nil { - WriteError(w, "could not decrypt note", err) + APIError(w, logger, ErrCouldNotDecryptNote, err) + return + } + } + + password, err := internal.Decode(body.Password) + if err != nil { + APIError(w, logger, ErrCouldNotDecodePassword, err) + return + } + + if len(note.PasswordHash) > 0 { + err := bcrypt.CompareHashAndPassword(note.PasswordHash, password) + if err != nil { + APIErrorBadRequest(w, logger, ErrInvalidPassword, err) return } } @@ -124,8 +232,10 @@ func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque } type ClientHandler struct { - logger *slog.Logger - version string + logger *slog.Logger + version string + baseURL string + baseDirectory string } func (h *ClientHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -140,10 +250,19 @@ func (h *ClientHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - version := h.version - if version == "" { - version = "latest" + // No disclosure of the version running on the server + if h.version == "" { + http.Redirect(w, r, fmt.Sprintf("%s/%s/%s-%s-%s", h.baseURL, "latest", clientName, os, arch), http.StatusMovedPermanently) + return } - http.Redirect(w, r, fmt.Sprintf("https://git.riou.xyz/jriou/%s/releases/download/%s/%s-%s-%s", clientName, version, clientName, os, arch), http.StatusMovedPermanently) + if h.baseDirectory != "" { + // Serve file locally + // Example: ./releases/1.2.0/coller-linux-amd64 + http.ServeFile(w, r, fmt.Sprintf("%s/%s/%s-%s-%s", h.baseDirectory, h.version, clientName, os, arch)) + } else { + // Redirect to a download link + // Example: https://git.riou.xyz/jriou/coller/releases/download/1.2.0/coller-linux-amd64 + http.Redirect(w, r, fmt.Sprintf("%s/%s/%s-%s-%s", h.baseURL, h.version, clientName, os, arch), http.StatusMovedPermanently) + } } diff --git a/src/server/handlers_web.go b/src/server/handlers_web.go index d059d7a..a3c018d 100644 --- a/src/server/handlers_web.go +++ b/src/server/handlers_web.go @@ -12,21 +12,43 @@ import ( "strings" "github.com/gorilla/mux" + "golang.org/x/crypto/bcrypt" "git.riou.xyz/jriou/coller/internal" ) 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 + Language string + Err error + URL string + Note *Note + EnablePasswordProtection bool + EnableUploadFileButton bool + AllowClientEncryptionKey bool + AllowNoEncryption bool + AceDirectory string + BootstrapDirectory string + DisableEditor bool + Password string // Not stored in the database +} + +func WebError(w http.ResponseWriter, pageData PageData, templates *template.Template, templateName string, logger *slog.Logger, topLevelErr error, err error) { + // Only show the top-level error to users + pageData.Err = topLevelErr + + // Show full error in the logs + if err != nil { + err = fmt.Errorf("%v: %w", topLevelErr, err) + } else { + err = pageData.Err + } + logger.Error(fmt.Sprintf("%v", err)) + templates.ExecuteTemplate(w, templateName, pageData) } type HomeHandler struct { @@ -46,109 +68,264 @@ type CreateNoteWithFormHandler struct { maxUploadSize int64 } +func (h *CreateNoteWithFormHandler) TemplateName() string { + return "create" +} + +func (h *CreateNoteWithFormHandler) Name() string { + return "CreateNoteWithFormHandler" +} + +func (h *CreateNoteWithFormHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err) +} + func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.PageData.Err = nil - templateName := "create" - h.logger.Debug("parsing multipart form") + logger := h.logger.With("handler", h.Name()) + + logger.Debug("parsing multipart form") err := r.ParseMultipartForm(h.maxUploadSize) if err != nil { - h.PageData.Err = err - h.Templates.ExecuteTemplate(w, templateName, h.PageData) + h.WebError(w, logger, ErrCouldNotParseForm, err) return } - h.logger.Debug("parsing content") + logger.Debug("parsing content") content := []byte(r.FormValue("content")) - h.logger.Debug("parsing file") + logger.Debug("parsing file") file, handler, err := r.FormFile("file") if err != nil && !errors.Is(err, http.ErrMissingFile) { - h.PageData.Err = err - h.Templates.ExecuteTemplate(w, templateName, h.PageData) + h.WebError(w, logger, ErrCouldNotParseFile, err) return } if !errors.Is(err, http.ErrMissingFile) { defer file.Close() - h.logger.Debug("checking file size") + logger.Debug("checking file size") if handler.Size > h.maxUploadSize { - h.PageData.Err = fmt.Errorf("file too large (%d > %d)", handler.Size, h.maxUploadSize) - h.Templates.ExecuteTemplate(w, templateName, h.PageData) + h.WebError(w, logger, ErrFileTooLarge, err) return } - h.logger.Debug("checking file content type") + logger.Debug("checking file content type") if !strings.HasPrefix(handler.Header.Get("Content-Type"), "text/") { - h.PageData.Err = fmt.Errorf("text file expected (got %s)", handler.Header.Get("Content-Type")) - h.Templates.ExecuteTemplate(w, templateName, h.PageData) + h.WebError(w, logger, ErrTextFileExpected, err) return } - h.logger.Debug("reading uploaded file") + logger.Debug("reading uploaded file") var fileContent bytes.Buffer n, err := io.Copy(&fileContent, file) if err != nil { - h.PageData.Err = err - h.Templates.ExecuteTemplate(w, templateName, h.PageData) + h.WebError(w, logger, ErrCouldNotReadFile, err) return } - h.logger.Debug("file uploaded", slog.Any("bytes", n)) + logger.Debug("file uploaded", slog.Any("bytes", n)) if n != 0 { content = fileContent.Bytes() } } - h.logger.Debug("checking content") + logger.Debug("checking content") if content == nil || len(content) == 0 { - h.PageData.Err = fmt.Errorf("empty note") - h.Templates.ExecuteTemplate(w, templateName, h.PageData) + h.WebError(w, logger, ErrEmptyNote, nil) return } - h.logger.Debug("checking inputs") - noPassword := r.FormValue("no-password") + logger.Debug("checking inputs") 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 !h.PageData.AllowNoEncryption && noEncryption != "" { + h.WebError(w, logger, ErrEncryptionRequired, nil) + return } - h.logger.Debug("computing expiration") + if !h.PageData.AllowClientEncryptionKey && encryptionKey != "" { + h.WebError(w, logger, ErrClientEncryptionKeyNotAllowed, nil) + return + } + + if !h.PageData.AllowClientEncryptionKey && encryptionKey == "" && noEncryption == "" { + logger.Debug("generating encryption key") + encryptionKey = internal.GenerateChars(encryptionKeyLength) + } + + logger.Debug("computing expiration") var expirationInt int if expiration == "Expiration" { expirationInt = 0 } else { - expirationInt, _ = strconv.Atoi(expiration) + expirationInt, err = strconv.Atoi(expiration) + if err != nil { + h.WebError(w, logger, ErrInvalidExpiration, err) + return + } } - h.logger.Debug("saving note to the database") - note, err := h.db.Create(content, password, password != "", expirationInt, deleteAfterRead != "", language) + logger.Debug("saving note to the database") + note, err := h.db.Create(content, []byte(password), encryptionKey, encryptionKey != "", expirationInt, deleteAfterRead != "", language) if err != nil { - h.PageData.Err = err - h.Templates.ExecuteTemplate(w, templateName, h.PageData) + h.WebError(w, logger, ErrCouldNotCreateNote, err) return } - h.logger.Debug("building note url") + logger.Debug("building note url") + var scheme = "http://" - if r.TLS != nil { + if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { + scheme = proto + "://" + } else if r.TLS != nil { scheme = "https://" } - h.PageData.URL = fmt.Sprintf("%s%s/%s", scheme, r.Host, note.ID) - if password != "" { - h.PageData.URL += "/" + password + h.PageData.URL = fmt.Sprintf("%s%s/%d.html", scheme, r.Host, note.ID) + if encryptionKey != "" { + h.PageData.URL += "#" + encryptionKey } - h.logger.Debug("rendering page") - h.Templates.ExecuteTemplate(w, "create", h.PageData) + logger.Debug("rendering page") + h.Templates.ExecuteTemplate(w, h.TemplateName(), h.PageData) +} + +type GetRawWebNoteHandler struct { + Templates *template.Template + PageData PageData + logger *slog.Logger + db *Database +} + +func (h *GetRawWebNoteHandler) TemplateName() string { + return "unprotectedNote" +} + +func (h *GetRawWebNoteHandler) Name() string { + return "GetRawWebNoteHandler" +} + +func (h *GetRawWebNoteHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err) +} + +func (h *GetRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.PageData.Err = nil + + vars := mux.Vars(r) + id := vars["id"] + + logger := h.logger.With("handler", h.Name(), "note_id", id) + + logger.Debug("fetching note from the database") + note, err := h.db.Get(id) + + if err != nil { + h.WebError(w, logger, ErrCouldNotFindNote, err) + return + } + + if note == nil { + h.WebError(w, logger, ErrNoteDoesNotExist, err) + return + } + + if note.Encrypted || len(note.PasswordHash) > 0 { + logger.Debug("rendering page") + h.PageData.Note = note + h.Templates.ExecuteTemplate(w, h.TemplateName(), h.PageData) + return + } + + logger.Debug("returning content") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, string(note.Content)) +} + +type GetProtectedRawWebNoteHandler struct { + Templates *template.Template + PageData PageData + logger *slog.Logger + db *Database + maxUploadSize int64 +} + +func (h *GetProtectedRawWebNoteHandler) TemplateName() string { + return "protectedNote" +} + +func (h *GetProtectedRawWebNoteHandler) Name() string { + return "GetProtectedRawWebNoteHandler" +} + +func (h *GetProtectedRawWebNoteHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err) +} + +func (h *GetProtectedRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.PageData.Err = nil + + vars := mux.Vars(r) + id := vars["id"] + + logger := h.logger.With("handler", h.Name(), "note_id", id) + + logger.Debug("parsing multipart form") + err := r.ParseMultipartForm(h.maxUploadSize) + if err != nil { + h.WebError(w, logger, ErrCouldNotParseForm, err) + return + } + + password := r.FormValue("password") + encryptionKey := r.FormValue("encryption-key") + + logger.Debug("fetching note from the database") + note, err := h.db.Get(id) + + if err != nil { + h.WebError(w, logger, ErrCouldNotFindNote, err) + return + } + + if note == nil { + h.WebError(w, logger, ErrNoteDoesNotExist, nil) + return + } + + if note.Encrypted { + if encryptionKey == "" { + h.WebError(w, logger, ErrEncryptionKeyNotFound, nil) + return + } + logger.Debug("decrypting content") + note.Content, err = internal.Decrypt(note.Content, encryptionKey) + if err != nil { + h.WebError(w, logger, ErrCouldNotDecryptNote, err) + return + } + } + + if len(note.PasswordHash) > 0 { + logger.Debug("comparing password hashes") + if err := bcrypt.CompareHashAndPassword(note.PasswordHash, []byte(password)); err != nil { + h.WebError(w, logger, ErrInvalidPassword, err) + return + } + } + + logger.Debug("returning content") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, string(note.Content)) } type GetWebNoteHandler struct { @@ -158,74 +335,124 @@ type GetWebNoteHandler struct { db *Database } +func (h *GetWebNoteHandler) TemplateName() string { + return "unprotectedNote" +} + +func (h *GetWebNoteHandler) Name() string { + return "GetWebNoteHandler" +} + +func (h *GetWebNoteHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err) +} + func (h *GetWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.PageData.Err = nil - templateName := "note" vars := mux.Vars(r) id := vars["id"] + logger := h.logger.With("handler", h.Name(), "note_id", id) + note, err := h.db.Get(id) if err != nil { - h.PageData.Err = fmt.Errorf("could not find note: %v", err) - h.Templates.ExecuteTemplate(w, templateName, h.PageData) + h.WebError(w, logger, ErrCouldNotFindNote, err) 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) + h.WebError(w, logger, ErrNoteDoesNotExist, nil) return } h.PageData.Note = note - h.logger.Debug("rendering note web page") - h.Templates.ExecuteTemplate(w, "note", h.PageData) + logger.Debug("rendering page") + h.Templates.ExecuteTemplate(w, h.TemplateName(), h.PageData) +} + +type GetProtectedWebNoteHandler struct { + Templates *template.Template + PageData PageData + logger *slog.Logger + db *Database + maxUploadSize int64 +} + +func (h *GetProtectedWebNoteHandler) TemplateName() string { + return "protectedNote" +} + +func (h *GetProtectedWebNoteHandler) Name() string { + return "GetProtectedWebNoteHandler" +} + +func (h *GetProtectedWebNoteHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) { + WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err) } func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.PageData.Err = nil - templateName := "note" vars := mux.Vars(r) id := vars["id"] - password := vars["password"] + + logger := h.logger.With("handler", h.Name(), "note_id", id) + + logger.Debug("parsing multipart form") + err := r.ParseMultipartForm(h.maxUploadSize) + if err != nil { + h.WebError(w, logger, ErrCouldNotParseForm, err) + return + } + + 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 find note: %v", err) - h.Templates.ExecuteTemplate(w, templateName, h.PageData) + h.WebError(w, logger, ErrCouldNotFindNote, err) return } if note == nil { - h.PageData.Err = fmt.Errorf("note doesn't exist or has been deleted") - h.Templates.ExecuteTemplate(w, templateName, h.PageData) + h.WebError(w, logger, ErrNoteDoesNotExist, nil) return } - if password != "" && note.Encrypted { - note.Content, err = internal.Decrypt(note.Content, password) + if note.Encrypted { + if encryptionKey == "" { + h.WebError(w, logger, ErrEncryptionKeyNotFound, nil) + return + } + 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) + h.WebError(w, logger, ErrCouldNotDecryptNote, err) return } } + if len(note.PasswordHash) > 0 { + if err := bcrypt.CompareHashAndPassword(note.PasswordHash, []byte(password)); err != nil { + h.WebError(w, logger, ErrInvalidPassword, err) + return + } + } + + h.PageData.Password = password h.PageData.Note = note - h.logger.Debug("rendering protected note web page") - h.Templates.ExecuteTemplate(w, "note", h.PageData) + logger.Debug("rendering page") + h.Templates.ExecuteTemplate(w, h.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 bff6b73..6f20e1a 100644 --- a/src/server/note.go +++ b/src/server/note.go @@ -1,47 +1,23 @@ 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"` + ID int64 `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"` } -// 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..77950a1 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -2,7 +2,6 @@ package server import ( "embed" - "encoding/json" "fmt" "html/template" "log/slog" @@ -16,10 +15,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,54 +40,15 @@ 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 +func (s *Server) SetEncryptionKeyLength(length int) { + encryptionKeyLength = length } func (s *Server) SetMetrics(metrics *Metrics) { s.metrics = metrics } -type ErrorResponse struct { - Message string `json:"message"` - Error string `json:"error"` -} - -func (e ErrorResponse) ToJSON() string { - b, err := json.Marshal(e) - if err == nil { - return string(b) - } - return fmt.Sprintf("{\"message\":\"could not serialize response to JSON\", \"error\":\"%v\"}", err) -} - -func WriteError(w http.ResponseWriter, message string, err error) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprint(w, ErrorResponse{ - Message: message, - Error: fmt.Sprintf("%v", err), - }.ToJSON()) -} - -type GetProtectedWebNoteHandler 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 { @@ -103,33 +63,59 @@ 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]+}").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") + + getProtectedNoteHandler := &GetProtectedNoteHandler{ + logger: s.logger, + db: s.db, + maxUploadSize: s.config.MaxUploadSize, + } + r.Path("/api/note/{id:[a-zA-Z0-9]+}").Handler(getProtectedNoteHandler).Methods("POST") + + getNoteHandler := &GetNoteHandler{ + logger: s.logger, + db: s.db, + } + r.Path("/api/note/{id:[a-zA-Z0-9]+}").Handler(getNoteHandler).Methods("GET") // Web pages funcs := template.FuncMap{ "HumanDuration": internal.HumanDuration, + "TimeDiff": internal.TimeDiff, "lower": strings.ToLower, "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, + Language: s.config.Language, + AceDirectory: s.config.AceDirectory, + DisableEditor: s.config.DisableEditor, + BootstrapDirectory: s.config.BootstrapDirectory, + EnableUploadFileButton: s.config.EnableUploadFileButton, + EnablePasswordProtection: s.config.EnablePasswordProtection, + 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, @@ -145,15 +131,14 @@ 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") - protectedWebNoteHandler := &GetProtectedWebNoteHandler{ - Templates: templates, - PageData: p, - logger: s.logger, - db: s.db, + clientHandler := &ClientHandler{ + logger: s.logger, + version: p.Version, + baseURL: s.config.ClientsBaseURL, + baseDirectory: s.config.ClientsBaseDirectory, } - r.Path("/{id:[a-zA-Z0-9]+}/{password:[a-zA-Z0-9]+}.html").Handler(protectedWebNoteHandler).Methods("GET") + r.Path("/clients/{os:[a-z]+}-{arch:[a-z0-9]+}/{clientName:[a-z]+}").Handler(clientHandler).Methods("GET") webNoteHandler := &GetWebNoteHandler{ Templates: templates, @@ -163,8 +148,40 @@ 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") + + 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)))) + } + if s.config.BootstrapDirectory != "" { - r.PathPrefix("/static/bootstrap/").Handler(http.StripPrefix("/static/bootstrap/", http.FileServer(http.Dir(s.config.BootstrapDirectory)))) + r.HandleFunc("/static/bootstrap/css/bootstrap.min.css", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, s.config.BootstrapDirectory+"/css/bootstrap.min.css") + }) } r.Path("/").Handler(&HomeHandler{Templates: templates, PageData: p}).Methods("GET") @@ -172,10 +189,10 @@ func (s *Server) Start() error { addr := fmt.Sprintf("%s:%d", s.config.ListenAddress, s.config.ListenPort) if s.config.HasTLS() { - s.logger.Info(fmt.Sprintf("listening to %s:%d (https)", s.config.ListenAddress, s.config.ListenPort)) + s.logger.Info(fmt.Sprintf("listening to %s:%d", s.config.ListenAddress, s.config.ListenPort), slog.Any("scheme", "http")) return http.ListenAndServeTLS(addr, s.config.TLSCertFile, s.config.TLSKeyFile, r) } else { - s.logger.Info(fmt.Sprintf("listening to %s:%d (http)", s.config.ListenAddress, s.config.ListenPort)) + s.logger.Info(fmt.Sprintf("listening to %s:%d", s.config.ListenAddress, s.config.ListenPort), slog.Any("scheme", "https")) return http.ListenAndServe(addr, r) } } 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/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/head.html b/src/server/templates/head.html index e5f3544..ab2edfd 100644 --- a/src/server/templates/head.html +++ b/src/server/templates/head.html @@ -4,6 +4,8 @@ {{.Title}} - + {{end}} \ No newline at end of file diff --git a/src/server/templates/index.html b/src/server/templates/index.html index 4c18a78..99da0cd 100644 --- a/src/server/templates/index.html +++ b/src/server/templates/index.html @@ -13,19 +13,31 @@
+ {{if .EnablePasswordProtection}}
- +
+ {{end}} + {{if .AllowClientEncryptionKey}}
- - +
+
+ +
+ {{end}} + {{if .AllowNoEncryption}} +
+ + +
+ {{end}}
@@ -40,15 +52,17 @@
@@ -56,11 +70,15 @@
+ {{if .DisableEditor}} + + {{else}}
+ style="min-height: 300px; resize: vertical; overflow: auto;">
+ {{end}}
@@ -69,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 2570851..03aca94 100644 --- a/src/server/templates/note.html +++ b/src/server/templates/note.html @@ -1,64 +1,129 @@ {{define "note"}} - - +{{if ne .Err nil}} +{{block "error" .}}{{end}} +{{else}} +
+
+ Note {{.Note.ID}} + +
+ + + + + + {{if .DisableEditor}} +
+
+{{string .Note.Content}}
+            
{{else}} -
-
- Note {{.Note.ID}} - +
+
-
-
-
- -
+ + {{end}} - - {{block "footer" .}}{{end}} - - - +
+{{end}} {{end}} \ No newline at end of file diff --git a/src/server/templates/protectedNote.html b/src/server/templates/protectedNote.html new file mode 100644 index 0000000..8b413d4 --- /dev/null +++ b/src/server/templates/protectedNote.html @@ -0,0 +1,21 @@ +{{define "protectedNote"}} + + + +{{block "head" .}}{{end}} + + + {{block "header" .}}{{end}} + + + {{if .Err}} + {{block "error" .}}{{end}} + {{else}} + {{block "note" .}}{{end}} + {{end}} + + {{block "footer" .}}{{end}} + + + +{{end}} \ No newline at end of file diff --git a/src/server/templates/unprotectedNote.html b/src/server/templates/unprotectedNote.html new file mode 100644 index 0000000..4df1de4 --- /dev/null +++ b/src/server/templates/unprotectedNote.html @@ -0,0 +1,70 @@ +{{define "unprotectedNote"}} + + + +{{block "head" .}}{{end}} + + + {{block "header" .}}{{end}} + + {{if .Err}} + {{block "error" .}}{{end}} + {{else if or (gt (len .Note.PasswordHash) 0) .Note.Encrypted}} + +
+
+ {{if gt (len .Note.PasswordHash) 0}} + +
+
+ +
+
+
+
+ +
+
+ {{end}} + {{if .Note.Encrypted}} +
+
+
+ +
+
+
+
+ +
+
+
+ + {{end}} +
+
+ +
+
+
+
+ {{else}} + {{block "note" .}}{{end}} + {{end}} + + {{block "footer" .}}{{end}} + + + +{{end}} \ No newline at end of file