Compare commits

...

4 commits

Author SHA1 Message Date
e3ed4da99b
feat: disable editor
All checks were successful
/ pre-commit (push) Successful in 1m47s
Signed-off-by: Julien Riou <julien@riou.xyz>
2025-09-24 07:10:59 +02:00
2ce17b0a3b
style: Simplify condition to load bootstrap
Signed-off-by: Julien Riou <julien@riou.xyz>
2025-09-24 07:10:57 +02:00
6d0ce573b3
feat: replace Monaco by Ace
- Remove the Monaco Editor because it was to heavy and hard to integrate
- Use Ace instead
- Use the lowercase identifier for languages (ex: "Text" -> "text")
- Select automatically the default language in the drop down to create a note
  (like the expiration)
- Add `ace_directory` to serve assets from a local folder instead of a CDN
- "hcl" syntax highlighting has been removed
- "go" syntax highlighting has been renamed to "golang"

Signed-off-by: Julien Riou <julien@riou.xyz>
2025-09-24 07:10:52 +02:00
8e1dd686d3
feat: Rename password by encryption key
All checks were successful
/ pre-commit (push) Successful in 1m9s
Signed-off-by: Julien Riou <julien@riou.xyz>
2025-09-24 07:09:01 +02:00
20 changed files with 232 additions and 190 deletions

6
package-lock.json generated
View file

@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"ace-builds": "^1.43.3",
"bootstrap": "^5.3.8" "bootstrap": "^5.3.8"
} }
}, },
@ -18,6 +19,11 @@
"url": "https://opencollective.com/popperjs" "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": { "node_modules/bootstrap": {
"version": "5.3.8", "version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",

View file

@ -1,5 +1,6 @@
{ {
"dependencies": { "dependencies": {
"ace-builds": "^1.43.3",
"bootstrap": "^5.3.8" "bootstrap": "^5.3.8"
} }
} }

View file

@ -22,17 +22,17 @@ Create from file:
coller -file filename.txt coller -file filename.txt
``` ```
Provide password for encryption: Provide encryption key:
``` ```
coller -ask-password coller -ask-encryption-key
coller -password PASSWORD 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: Return the copier command to use client-side decryption instead of the URL:

View file

@ -64,10 +64,10 @@ func handleMain() int {
configFile := flag.String("config", filepath.Join(homeDir, ".config", AppName+".json"), "Configuration file") configFile := flag.String("config", filepath.Join(homeDir, ".config", AppName+".json"), "Configuration file")
reconfigure := flag.Bool("reconfigure", false, "Re-create configuration file") reconfigure := flag.Bool("reconfigure", false, "Re-create configuration file")
url := flag.String("url", "", "URL of the coller API") url := flag.String("url", "", "URL of the coller API")
password := flag.String("password", os.Getenv("COLLER_PASSWORD"), "Password to encrypt the note") encryptionKey := flag.String("encryption-key", os.Getenv("COLLER_ENCRYPTION_KEY"), "Key to encrypt the note")
askPassword := flag.Bool("ask-password", false, "Read password from input") askEncryptionKey := flag.Bool("ask-encryption-key", false, "Read encryption key from input")
noPassword := flag.Bool("no-password", false, "Allow notes without password") noEncryption := flag.Bool("no-encryption", false, "Allow notes without encryption key")
passwordLength := flag.Int("password-length", 16, "Length of the auto-generated password") 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") flag.StringVar(&fileName, "file", "", "Read content of the note from a file")
expiration := flag.Int("expiration", 0, "Number of seconds before expiration") expiration := flag.Int("expiration", 0, "Number of seconds before expiration")
deleteAfterRead := flag.Bool("delete-after-read", false, "Delete the note after the first read") 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) content = clipboard.Read(clipboard.FmtText)
} }
if *askPassword { if *askEncryptionKey {
fmt.Print("Password: ") fmt.Print("Encryption key: ")
p, err := term.ReadPassword(int(syscall.Stdin)) p, err := term.ReadPassword(int(syscall.Stdin))
if err != nil { 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") fmt.Print("\n")
} }
if !*noPassword && *password == "" { if !*noEncryption && *encryptionKey == "" {
logger.Debug("generating random password") logger.Debug("generating random encryption key")
if *passwordLength < internal.MIN_PASSWORD_LENGTH || *passwordLength > internal.MAX_PASSWORD_LENGTH { if *encryptionKeyLength < internal.MIN_ENCRYPTION_KEY_LENGTH || *encryptionKeyLength > internal.MAX_ENCRYPTION_KEY_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)) 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 { if len(content) == 0 {
@ -173,13 +173,13 @@ func handleMain() int {
p.Language = *language p.Language = *language
} }
if *password != "" { if *encryptionKey != "" {
logger.Debug("validating password") logger.Debug("validating encryption key")
if err = internal.ValidatePassword(*password); err != nil { if err = internal.ValidateEncryptionKey(*encryptionKey); err != nil {
return internal.ReturnError(logger, "invalid password", nil) return internal.ReturnError(logger, "invalid encryption key", nil)
} }
logger.Debug("encrypting content") logger.Debug("encrypting content")
content, err = internal.Encrypt(content, *password) content, err = internal.Encrypt(content, *encryptionKey)
if err != nil { if err != nil {
return internal.ReturnError(logger, "could not encrypt note", err) return internal.ReturnError(logger, "could not encrypt note", err)
} }
@ -242,21 +242,21 @@ func handleMain() int {
logger.Debug("finding note location") logger.Debug("finding note location")
var location string var location string
noteURL := *url + "/" + jsonBody.ID noteURL := *url + "/" + jsonBody.ID
if *password != "" { if *encryptionKey != "" {
if *copier { if *copier {
location = fmt.Sprintf("copier -password %s %s", *password, noteURL) location = fmt.Sprintf("copier -encryption-key %s %s", *encryptionKey, noteURL)
} else { } else {
if *html { if *html {
location = fmt.Sprintf("%s/%s.html", noteURL, *password) location = fmt.Sprintf("%s/%s.html", noteURL, *encryptionKey)
} else { } else {
location = fmt.Sprintf("%s/%s", noteURL, *password) location = fmt.Sprintf("%s/%s", noteURL, *encryptionKey)
} }
} }
} else { } else {
if *html { if *html {
location = fmt.Sprintf("%s.html", noteURL) location = fmt.Sprintf("%s.html", noteURL)
} else { } else {
location = fmt.Sprintf("%s", noteURL) location = noteURL
} }
} }

View file

@ -19,7 +19,7 @@ The file format is **JSON**:
* **database_type** (string): Type of the database (default "sqlite", "postgres" also supported) * **database_type** (string): Type of the database (default "sqlite", "postgres" also supported)
* **database_dsn** (string): Connection string for the database (default "collerd.db") * **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)) * **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 * **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_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) * **listen_port** (int): Port to listen for the web server (default 8080)
@ -31,12 +31,14 @@ The file format is **JSON**:
* **prometheus_route** (string): Route to expose Prometheus metrics (default "/metrics") * **prometheus_route** (string): Route to expose Prometheus metrics (default "/metrics")
* **prometheus_notes_metric** (string): Name of the notes count metric (default "collerd_notes") * **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) * **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") * **language** (string): Default language (default "text")
* **enable_upload_file_button** (bool): Display the upload file button in the UI (default true) * **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_cert_file** (string): Path to TLS certificate file to enable HTTPS
* **tls_key_file** (string): Path to TLS key 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. * **bootstrap_directory** (string): Serve [Bootstrap](https://getbootstrap.com/) assets from this local directory (ex: "./node_modules/bootstrap/dist"). See **Dependencies** for details.
* **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 not required but the service might not be exposed to the public.
@ -52,7 +54,7 @@ Create a note.
Body (JSON): Body (JSON):
* **content** (string): base64 encoded content (required) * **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 * **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) * **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 * **delete_after_read** (bool): delete the note after the first read
@ -62,12 +64,12 @@ Response (JSON):
* **id** (string): ID of the note * **id** (string): ID of the note
### GET /\<id\>/\<password\> ### GET /\<id\>/\<encryptionKey\>
> [!WARNING] > [!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 /\<id\> ### GET /\<id\>
@ -85,12 +87,12 @@ Errors return **500 Server Internal Error** with the **JSON** payload:
The web interface depends on: The web interface depends on:
- [Ace](https://ace.c9.io/)
- [Bootstrap](https://getbootstrap.com/) - [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. 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 npm install
@ -106,8 +108,7 @@ Then configure the local directories:
```json ```json
{ {
"ace_directory": "./node_modules/ace-builds",
"bootstrap_directory": "./node_modules/bootstrap/dist" "bootstrap_directory": "./node_modules/bootstrap/dist"
} }
``` ```
Downloading Monaco Editor is not supported yet.

View file

@ -70,7 +70,7 @@ func handleMain() int {
return internal.ReturnError(logger, "could not create server", err) return internal.ReturnError(logger, "could not create server", err)
} }
srv.SetPasswordLength(config.PasswordLength) srv.SetEncryptionKeyLength(config.EncryptionKeyLength)
if config.EnableMetrics { if config.EnableMetrics {
reg := prometheus.NewRegistry() reg := prometheus.NewRegistry()

View file

@ -11,6 +11,6 @@ copier -help
# Examples # Examples
``` ```
copier -password PASSWORD URL copier -encryption-key ENCRYPTION_KEY URL
copier -ask-password URL copier -ask-encryption-key URL
``` ```

View file

@ -9,8 +9,9 @@ import (
"os" "os"
"syscall" "syscall"
"git.riou.xyz/jriou/coller/internal"
"golang.org/x/term" "golang.org/x/term"
"git.riou.xyz/jriou/coller/internal"
) )
var ( var (
@ -28,8 +29,8 @@ func handleMain() int {
quiet := flag.Bool("quiet", false, "Log errors only") quiet := flag.Bool("quiet", false, "Log errors only")
verbose := flag.Bool("verbose", false, "Print more logs") verbose := flag.Bool("verbose", false, "Print more logs")
debug := flag.Bool("debug", false, "Print even more logs") debug := flag.Bool("debug", false, "Print even more logs")
password := flag.String("password", os.Getenv("COLLER_PASSWORD"), "Password to decrypt the note") encryptionKey := flag.String("encryption-key", os.Getenv("COLLER_ENCRYPTION_KEY"), "Key to decrypt the note")
askPassword := flag.Bool("ask-password", false, "Read password from input") askEncryptionKey := flag.Bool("ask-encryption-key", false, "Read encryption key from input")
fileName := flag.String("file", "", "Write content of the note to a file") fileName := flag.String("file", "", "Write content of the note to a file")
bearer := flag.String("bearer", os.Getenv("COLLER_BEARER"), "Bearer token") bearer := flag.String("bearer", os.Getenv("COLLER_BEARER"), "Bearer token")
askBearer := flag.Bool("ask-bearer", false, "Read bearer token from input") 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})) logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
if *askPassword { if *askEncryptionKey {
fmt.Print("Password: ") fmt.Print("Encryption key: ")
p, err := term.ReadPassword(int(syscall.Stdin)) p, err := term.ReadPassword(int(syscall.Stdin))
if err != nil { 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") fmt.Print("\n")
} }
@ -102,11 +103,11 @@ func handleMain() int {
} }
var content []byte var content []byte
if *password != "" { if *encryptionKey != "" {
logger.Debug("decrypting note") logger.Debug("decrypting note")
content, err = internal.Decrypt(body, *password) content, err = internal.Decrypt(body, *encryptionKey)
if err != nil { if err != nil {
return internal.ReturnError(logger, "could not decrypt paste", err) return internal.ReturnError(logger, "could not decrypt note", err)
} }
} else { } else {
content = body content = body

View file

@ -19,21 +19,21 @@ const (
// NewCipher creates a cipher using XChaCha20-Poly1305 // NewCipher creates a cipher using XChaCha20-Poly1305
// https://pkg.go.dev/golang.org/x/crypto/chacha20poly1305 // https://pkg.go.dev/golang.org/x/crypto/chacha20poly1305
// A salt is required to derive the key from a password using argon // A salt is required to derive the key from an encryption key using argon
func NewCipher(password string, salt []byte) (cipher.AEAD, error) { func NewCipher(encryptionKey string, salt []byte) (cipher.AEAD, error) {
key := argon2.IDKey([]byte(password), salt, KeyTime, KeyMemory, KeyThreads, KeySize) key := argon2.IDKey([]byte(encryptionKey), salt, KeyTime, KeyMemory, KeyThreads, KeySize)
return chacha20poly1305.NewX(key) 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 // 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) salt := make([]byte, SaltSize)
if n, err := rand.Read(salt); err != nil || n != SaltSize { if n, err := rand.Read(salt); err != nil || n != SaltSize {
return nil, err return nil, err
} }
aead, err := NewCipher(password, salt) aead, err := NewCipher(encryptionKey, salt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -53,15 +53,15 @@ func Encrypt(plaintext []byte, password string) (result []byte, err error) {
return result, nil return result, nil
} }
// Decrypt to decrypt a ciphertext with a password // Decrypt to decrypt a ciphertext with a encryption key
// Returns the plaintext // Returns the plaintext
func Decrypt(ciphertext []byte, password string) ([]byte, error) { func Decrypt(ciphertext []byte, encryptionKey string) ([]byte, error) {
if len(ciphertext) < SaltSize { if len(ciphertext) < SaltSize {
return nil, fmt.Errorf("ciphertext is too short: cannot read salt") return nil, fmt.Errorf("ciphertext is too short: cannot read salt")
} }
salt := ciphertext[:SaltSize] salt := ciphertext[:SaltSize]
aead, err := NewCipher(password, salt) aead, err := NewCipher(encryptionKey, salt)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -6,10 +6,10 @@ import (
func TestEncryptAndDecrypt(t *testing.T) { func TestEncryptAndDecrypt(t *testing.T) {
plaintext := "test" plaintext := "test"
password := "test" encryptionKey := "test"
wrongPassword := password + "wrong" wrongEncryptionKey := encryptionKey + "wrong"
ciphertext, err := Encrypt([]byte(plaintext), password) ciphertext, err := Encrypt([]byte(plaintext), encryptionKey)
if err != nil { if err != nil {
t.Errorf("unexpected error when encrypting: %v", err) t.Errorf("unexpected error when encrypting: %v", err)
return return
@ -20,7 +20,7 @@ func TestEncryptAndDecrypt(t *testing.T) {
return return
} }
cleartext, err := Decrypt(ciphertext, password) cleartext, err := Decrypt(ciphertext, encryptionKey)
if err != nil { if err != nil {
t.Errorf("unexpected error when decrypting: %v", err) t.Errorf("unexpected error when decrypting: %v", err)
return return
@ -31,14 +31,14 @@ func TestEncryptAndDecrypt(t *testing.T) {
return return
} }
if password == wrongPassword { if encryptionKey == wrongEncryptionKey {
t.Errorf("passwords must be different") t.Errorf("encryption keys must be different")
return return
} }
_, err = Decrypt(ciphertext, wrongPassword) _, err = Decrypt(ciphertext, wrongEncryptionKey)
if err == nil { 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 return
} }
} }

View file

@ -1,8 +1,8 @@
package internal package internal
const ( const (
RC_OK = 0 RC_OK = 0
RC_ERROR = 1 RC_ERROR = 1
MIN_PASSWORD_LENGTH = 16 MIN_ENCRYPTION_KEY_LENGTH = 16
MAX_PASSWORD_LENGTH = 256 MAX_ENCRYPTION_KEY_LENGTH = 256
) )

View file

@ -58,13 +58,13 @@ func GenerateChars(n int) string {
return string(b) 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 // 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 { func ValidateEncryptionKey(p string) error {
if !passwordRegexp.MatchString(p) { if !encryptionKeyRegexp.MatchString(p) {
return fmt.Errorf("password doesn't match '%s'", passwordRegexp) return fmt.Errorf("encryption key doesn't match '%s'", encryptionKeyRegexp)
} }
return nil return nil
} }

View file

@ -11,7 +11,7 @@ type Config struct {
DatabaseType string `json:"database_type"` DatabaseType string `json:"database_type"`
DatabaseDsn string `json:"database_dsn"` DatabaseDsn string `json:"database_dsn"`
NodeID int64 `json:"node_id"` NodeID int64 `json:"node_id"`
PasswordLength int `json:"password_length"` EncryptionKeyLength int `json:"encryption_key_length"`
ExpirationInterval int `json:"expiration_interval"` ExpirationInterval int `json:"expiration_interval"`
ListenAddress string `json:"listen_address"` ListenAddress string `json:"listen_address"`
ListenPort int `json:"listen_port"` ListenPort int `json:"listen_port"`
@ -28,19 +28,21 @@ type Config struct {
EnableUploadFileButton bool `json:"enable_upload_file_button"` EnableUploadFileButton bool `json:"enable_upload_file_button"`
TLSCertFile string `json:"tls_cert_file"` TLSCertFile string `json:"tls_cert_file"`
TLSKeyFile string `json:"tls_key_file"` TLSKeyFile string `json:"tls_key_file"`
AceDirectory string `json:"ace_directory"`
BootstrapDirectory string `json:"bootstrap_directory"` BootstrapDirectory string `json:"bootstrap_directory"`
DisableEditor bool `json:"disable_editor"`
} }
func NewConfig() *Config { func NewConfig() *Config {
return &Config{ return &Config{
Title: "Coller", Title: "Coller",
DatabaseType: "sqlite", DatabaseType: "sqlite",
DatabaseDsn: "collerd.db", DatabaseDsn: "collerd.db",
NodeID: 1, NodeID: 1,
PasswordLength: 16, EncryptionKeyLength: 16,
ExpirationInterval: 60, // 1 minute ExpirationInterval: 60, // 1 minute
ListenAddress: "0.0.0.0", ListenAddress: "0.0.0.0",
ListenPort: 8080, ListenPort: 8080,
Expirations: []int{ Expirations: []int{
300, // 5 minutes 300, // 5 minutes
3600, // 1 hour 3600, // 1 hour
@ -56,22 +58,21 @@ func NewConfig() *Config {
PrometheusNotesMetric: "collerd_notes", PrometheusNotesMetric: "collerd_notes",
ObservationInterval: 60, ObservationInterval: 60,
Languages: []string{ Languages: []string{
"Text", "css",
"CSS", "dockerfile",
"Dockerfile", "golang",
"Go", "html",
"HCL", "javascript",
"HTML", "json",
"Javascript", "markdown",
"JSON", "perl",
"Markdown", "python",
"Perl", "ruby",
"Python", "rust",
"Ruby", "sh",
"Rust", "sql",
"Shell", "text",
"SQL", "yaml",
"YAML",
}, },
Language: "text", Language: "text",
EnableUploadFileButton: true, EnableUploadFileButton: true,
@ -92,8 +93,8 @@ func (c *Config) Check() error {
return fmt.Errorf("node id must be between 0 and 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 { if c.EncryptionKeyLength < internal.MIN_ENCRYPTION_KEY_LENGTH || c.EncryptionKeyLength > internal.MAX_ENCRYPTION_KEY_LENGTH {
return fmt.Errorf("password length must be between %d and %d", internal.MIN_PASSWORD_LENGTH, internal.MAX_PASSWORD_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 return nil
} }

View file

@ -122,7 +122,7 @@ func (d *Database) Get(id string) (*Note, error) {
return nil, nil 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 { if expiration == 0 {
expiration = d.expiration expiration = d.expiration
} }
@ -148,11 +148,11 @@ func (d *Database) Create(content []byte, password string, encrypted bool, expir
DeleteAfterRead: deleteAfterRead, DeleteAfterRead: deleteAfterRead,
Language: language, Language: language,
} }
if password != "" { if encryptionKey != "" {
if err = internal.ValidatePassword(password); err != nil { if err = internal.ValidateEncryptionKey(encryptionKey); err != nil {
return nil, err return nil, err
} }
note.Content, err = internal.Encrypt(note.Content, password) note.Content, err = internal.Encrypt(note.Content, encryptionKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -23,7 +23,7 @@ type CreateNoteHandler struct {
type CreateNotePayload struct { type CreateNotePayload struct {
Content string `json:"content"` Content string `json:"content"`
Password string `json:"password"` EncryptionKey string `json:"encryption_key"`
Encrypted bool `json:"encrypted"` Encrypted bool `json:"encrypted"`
Expiration int `json:"expiration"` Expiration int `json:"expiration"`
DeleteAfterRead bool `json:"delete_after_read"` DeleteAfterRead bool `json:"delete_after_read"`
@ -54,7 +54,7 @@ func (h *CreateNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
WriteError(w, "could not create note", err) WriteError(w, "could not create note", err)
return return
@ -99,7 +99,7 @@ func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
vars := mux.Vars(r) vars := mux.Vars(r)
id := vars["id"] id := vars["id"]
password := vars["password"] encryptionKey := vars["encryptionKey"]
note, err := h.db.Get(id) note, err := h.db.Get(id)
@ -111,8 +111,8 @@ func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
return return
} }
if password != "" && note.Encrypted { if encryptionKey != "" && note.Encrypted {
note.Content, err = internal.Decrypt(note.Content, password) note.Content, err = internal.Decrypt(note.Content, encryptionKey)
if err != nil { if err != nil {
WriteError(w, "could not decrypt note", err) WriteError(w, "could not decrypt note", err)
return return

View file

@ -21,12 +21,15 @@ type PageData struct {
Version string Version string
Expirations []int Expirations []int
Expiration int Expiration int
Language string
Languages []string Languages []string
Err error Err error
URL string URL string
Note *Note Note *Note
EnableUploadFileButton bool EnableUploadFileButton bool
AceDirectory string
BootstrapDirectory string BootstrapDirectory string
DisableEditor bool
} }
type HomeHandler struct { type HomeHandler struct {
@ -109,15 +112,15 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
} }
h.logger.Debug("checking inputs") h.logger.Debug("checking inputs")
noPassword := r.FormValue("no-password") noEncryption := r.FormValue("no-encryption")
password := r.FormValue("password") encryptionKey := r.FormValue("encryption-key")
expiration := r.FormValue("expiration") expiration := r.FormValue("expiration")
deleteAfterRead := r.FormValue("delete-after-read") deleteAfterRead := r.FormValue("delete-after-read")
language := r.FormValue("language") language := r.FormValue("language")
if password == "" && noPassword == "" { if encryptionKey == "" && noEncryption == "" {
h.logger.Debug("generating password") h.logger.Debug("generating encryption key")
password = internal.GenerateChars(passwordLength) encryptionKey = internal.GenerateChars(encryptionKeyLength)
} }
h.logger.Debug("computing expiration") h.logger.Debug("computing expiration")
@ -129,7 +132,7 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
} }
h.logger.Debug("saving note to the database") 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 { if err != nil {
h.PageData.Err = err h.PageData.Err = err
h.Templates.ExecuteTemplate(w, templateName, h.PageData) h.Templates.ExecuteTemplate(w, templateName, h.PageData)
@ -143,8 +146,8 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
} }
h.PageData.URL = fmt.Sprintf("%s%s/%s", scheme, r.Host, note.ID) h.PageData.URL = fmt.Sprintf("%s%s/%s", scheme, r.Host, note.ID)
if password != "" { if encryptionKey != "" {
h.PageData.URL += "/" + password h.PageData.URL += "/" + encryptionKey
} }
h.logger.Debug("rendering page") h.logger.Debug("rendering page")
@ -197,7 +200,7 @@ func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
vars := mux.Vars(r) vars := mux.Vars(r)
id := vars["id"] id := vars["id"]
password := vars["password"] encryptionKey := vars["encryptionKey"]
note, err := h.db.Get(id) note, err := h.db.Get(id)
@ -213,8 +216,8 @@ func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
return return
} }
if password != "" && note.Encrypted { if encryptionKey != "" && note.Encrypted {
note.Content, err = internal.Decrypt(note.Content, password) note.Content, err = internal.Decrypt(note.Content, encryptionKey)
if err != nil { if err != nil {
h.PageData.Err = fmt.Errorf("could not decrypt note: %v", err) h.PageData.Err = fmt.Errorf("could not decrypt note: %v", err)
h.Templates.ExecuteTemplate(w, templateName, h.PageData) h.Templates.ExecuteTemplate(w, templateName, h.PageData)

View file

@ -16,10 +16,10 @@ import (
) )
var ( var (
passwordLength = internal.MIN_PASSWORD_LENGTH encryptionKeyLength = internal.MIN_ENCRYPTION_KEY_LENGTH
supportedOSes = []string{"linux", "darwin"} supportedOSes = []string{"linux", "darwin"}
supportedArches = []string{"amd64", "arm64"} supportedArches = []string{"amd64", "arm64"}
supportedClients = []string{"coller", "copier"} supportedClients = []string{"coller", "copier"}
) )
type Server struct { type Server struct {
@ -41,8 +41,8 @@ func NewServer(logger *slog.Logger, db *Database, config *Config, version string
}, nil }, nil
} }
func (s *Server) SetPasswordLength(length int) { func (s *Server) SetEncryptionKeyLength(length int) {
passwordLength = length encryptionKeyLength = length
} }
func (s *Server) SetMetrics(metrics *Metrics) { func (s *Server) SetMetrics(metrics *Metrics) {
@ -100,7 +100,7 @@ func (s *Server) Start() error {
// API // API
r.Path("/api/note").Handler(&CreateNoteHandler{logger: s.logger, db: s.db, maxUploadSize: s.config.MaxUploadSize}).Methods("POST") 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") r.Path("/{id:[a-zA-Z0-9]+}").Handler(&GetNoteHandler{logger: s.logger, db: s.db}).Methods("GET")
// Web pages // Web pages
@ -114,8 +114,11 @@ func (s *Server) Start() error {
Title: s.config.Title, Title: s.config.Title,
Expirations: s.config.Expirations, Expirations: s.config.Expirations,
Expiration: s.config.Expiration, Expiration: s.config.Expiration,
Language: s.config.Language,
Languages: s.config.Languages, Languages: s.config.Languages,
AceDirectory: s.config.AceDirectory,
BootstrapDirectory: s.config.BootstrapDirectory, BootstrapDirectory: s.config.BootstrapDirectory,
DisableEditor: s.config.DisableEditor,
} }
if s.config.ShowVersion { if s.config.ShowVersion {
@ -150,7 +153,7 @@ func (s *Server) Start() error {
logger: s.logger, logger: s.logger,
db: s.db, 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{ webNoteHandler := &GetWebNoteHandler{
Templates: templates, Templates: templates,
@ -160,6 +163,10 @@ func (s *Server) Start() error {
} }
r.Path("/{id:[a-zA-Z0-9]+}.html").Handler(webNoteHandler).Methods("GET") r.Path("/{id:[a-zA-Z0-9]+}.html").Handler(webNoteHandler).Methods("GET")
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 != "" { if s.config.BootstrapDirectory != "" {
r.PathPrefix("/static/bootstrap/").Handler(http.StripPrefix("/static/bootstrap/", http.FileServer(http.Dir(s.config.BootstrapDirectory)))) r.PathPrefix("/static/bootstrap/").Handler(http.StripPrefix("/static/bootstrap/", http.FileServer(http.Dir(s.config.BootstrapDirectory))))
} }
@ -169,10 +176,10 @@ func (s *Server) Start() error {
addr := fmt.Sprintf("%s:%d", s.config.ListenAddress, s.config.ListenPort) addr := fmt.Sprintf("%s:%d", s.config.ListenAddress, s.config.ListenPort)
if s.config.HasTLS() { 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) return http.ListenAndServeTLS(addr, s.config.TLSCertFile, s.config.TLSKeyFile, r)
} else { } 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) return http.ListenAndServe(addr, r)
} }
} }

View file

@ -4,6 +4,8 @@
<title>{{.Title}}</title> <title>{{.Title}}</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="{{if ne .BootstrapDirectory ``}}/static/bootstrap/css/bootstrap.min.css{{else}}https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css{{end}}" rel="stylesheet"> <link
href="{{if .BootstrapDirectory}}/static/bootstrap/css/bootstrap.min.css{{else}}https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css{{end}}"
rel="stylesheet">
</head> </head>
{{end}} {{end}}

View file

@ -14,17 +14,17 @@
<div class="container text-center justify-content-center w-75 mb-4"> <div class="container text-center justify-content-center w-75 mb-4">
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col-1"> <div class="col-1">
<label class="col-form-label col-form-label-sm" for="password">Password</label> <label class="col-form-label col-form-label-sm" for="encryption-key">Encryption key</label>
</div> </div>
<div class="col"> <div class="col">
<input type="password" pattern="^[a-zA-Z0-9]{16,256}$" <input type="password" pattern="^[a-zA-Z0-9]{16,256}$"
title="Letters and numbers with length from 16 to 256" class="form-control" id="password" title="Letters and numbers with length from 16 to 256" class="form-control" id="encryption-key"
name="password"> name="encryption-key">
</div> </div>
<div class="col-1"> <div class="col-1">
<input type="checkbox" class="form-check-input" for="no-password" id="no-password" <input type="checkbox" class="form-check-input" for="no-encryption-key" id="no-encryption-key"
value="no-password" name="no-password"> value="no-encryption-key" name="no-encryption-key">
<label class="col-form-label col-form-label-sm" for="no-password">No password</label> <label class="col-form-label col-form-label-sm" for="no-encryption-key">No encryption</label>
</div> </div>
<div class="col-1"> <div class="col-1">
<input type="checkbox" class="form-check-input" for="delete-after-read" id="delete-after-read" <input type="checkbox" class="form-check-input" for="delete-after-read" id="delete-after-read"
@ -40,15 +40,17 @@
<select class="form-select" aria-label="Expiration" id="expiration" name="expiration"> <select class="form-select" aria-label="Expiration" id="expiration" name="expiration">
<option disabled>Expiration</option> <option disabled>Expiration</option>
{{range $exp := .Expirations}} {{range $exp := .Expirations}}
<option {{ if eq $exp $.Expiration }}selected="selected"{{end}} value="{{$exp}}">{{HumanDuration $exp}}</option> <option {{ if eq $exp $.Expiration }}selected="selected" {{end}} value="{{$exp}}">
{{HumanDuration $exp}}</option>
{{end}} {{end}}
</select> </select>
</div> </div>
<div class="col"> <div class="col">
<select class="form-select" aria-label="Language" id="language" name="language"> <select class="form-select" aria-label="Language" id="language" name="language">
<option selected="selected" value="" disabled>Language</option> <option disabled>Language</option>
{{range .Languages}} {{range $language := .Languages}}
<option value="{{lower .}}">{{.}}</option> <option {{ if eq $language $.Language }}selected="selected" {{end}}value="{{lower .}}">
{{$language}}</option>
{{end}} {{end}}
</select> </select>
</div> </div>
@ -56,11 +58,15 @@
</div> </div>
<div class="container mb-4"> <div class="container mb-4">
{{if .DisableEditor}}
<textarea class="form-control" id="content" name="content" cols="40" rows="12"></textarea>
{{else}}
<div class="row"> <div class="row">
<div id="editor" name="editor" class="form-control" <div id="editor" name="editor" class="form-control"
style="height: 300px; resize: vertical; overflow: auto;"></div> style="height: 300px; resize: vertical; overflow: auto;"></div>
<input type="hidden" id="content" /> <input type="hidden" id="content" />
</div> </div>
{{end}}
</div> </div>
<div class="container mb-4"> <div class="container mb-4">
<div class="row text-center justify-content-center"> <div class="row text-center justify-content-center">
@ -69,38 +75,40 @@
</div> </div>
</div> </div>
</div> </div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs/loader.min.js"></script> {{if eq false .DisableEditor}}
<script
src="{{if .AceDirectory}}/static/ace-builds{{else}}https://cdn.jsdelivr.net/npm/ace-builds@1.43.3{{end}}/src-noconflict/ace.js"
type="text/javascript" charset="utf-8"></script>
<script> <script>
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs' } }); var editor = ace.edit("editor");
if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
editor.setTheme("ace/theme/github_light_default");
} else {
editor.setTheme("ace/theme/github_dark");
}
require(['vs/editor/editor.main'], function () { // Syntax highlighting
var editor = monaco.editor.create(document.getElementById('editor'), { document.getElementById("language").addEventListener("change", (e) => {
theme: document.documentElement.getAttribute('data-bs-theme') == 'light' ? "vs" : "vs-dark", if (e.target.value != "") {
language: document.getElementById("language").value, editor.getSession().setMode("ace/mode/" + e.target.value);
}); }
});
// Syntax highlighting // Dark mode
document.getElementById("language").addEventListener("change", (e) => { document.getElementById("lightSwitch").addEventListener("click", () => {
if (e.target.value != "") { if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
monaco.editor.setModelLanguage(editor.getModel(), e.target.value); editor.setTheme("ace/theme/github_light_default")
} } else if (document.documentElement.getAttribute('data-bs-theme') == 'dark') {
}); editor.setTheme("ace/theme/github_dark")
}
});
// Dark mode // Copy content on submit
document.getElementById("lightSwitch").addEventListener("click", () => { document.getElementById("form").addEventListener("formdata", (e) => {
if (document.documentElement.getAttribute('data-bs-theme') == 'light') { e.formData.append('content', editor.getValue());
monaco.editor.setTheme("vs")
} else if (document.documentElement.getAttribute('data-bs-theme') == 'dark') {
monaco.editor.setTheme("vs-dark")
}
});
// Copy content on submit
document.getElementById("form").addEventListener("formdata", (e) => {
e.formData.append('content', editor.getModel().getValue());
});
}); });
</script> </script>
{{end}}
</form> </form>
{{block "footer" .}}{{end}} {{block "footer" .}}{{end}}

View file

@ -33,32 +33,44 @@
{{end}} {{end}}
</ul> </ul>
</div> </div>
{{if .DisableEditor}}
<div class="row">
<pre class="border px-3 pt-3" style="min-height: 300px; resize: vertical; overflow: auto;">
{{string .Note.Content}}
</pre>
</div>
{{else}}
<div class="row"> <div class="row">
<div id="editor" name="editor" class="form-control" <div id="editor" name="editor" class="form-control"
style="height: 300px; resize: vertical; overflow: auto;"></div> style="min-height: 300px; resize: vertical; overflow: auto;"></div>
</div> </div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs/loader.min.js"></script> <script
src="{{if .AceDirectory}}/static/ace-builds{{else}}https://cdn.jsdelivr.net/npm/ace-builds@1.43.3{{end}}/src-noconflict/ace.js"
type="text/javascript" charset="utf-8"></script>
<script> <script>
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs' } }); var editor = ace.edit("editor");
editor.setValue("{{string .Note.Content}}");
editor.setReadOnly(true);
editor.getSession().setMode("ace/mode/{{.Note.Language}}");
editor.getSession().selection.clearSelection();
editor.setOptions({maxLines: Infinity});
require(['vs/editor/editor.main'], function () { if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
var editor = monaco.editor.create(document.getElementById('editor'), { editor.setTheme("ace/theme/github_light_default");
theme: document.documentElement.getAttribute('data-bs-theme') == 'light' ? "vs" : "vs-dark", } else {
language: "{{.Note.Language}}", editor.setTheme("ace/theme/github_dark");
readOnly: true, }
value: "{{string .Note.Content}}"
});
// Dark mode // Dark mode
document.getElementById("lightSwitch").addEventListener("click", () => { document.getElementById("lightSwitch").addEventListener("click", () => {
if (document.documentElement.getAttribute('data-bs-theme') == 'light') { if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
monaco.editor.setTheme("vs") editor.setTheme("ace/theme/github_light_default")
} else if (document.documentElement.getAttribute('data-bs-theme') == 'dark') { } else if (document.documentElement.getAttribute('data-bs-theme') == 'dark') {
monaco.editor.setTheme("vs-dark") editor.setTheme("ace/theme/github_dark")
} }
});
}); });
</script> </script>
{{end}}
</div> </div>
{{end}} {{end}}