Compare commits
4 commits
0d822e2a38
...
e3ed4da99b
Author | SHA1 | Date | |
---|---|---|---|
e3ed4da99b |
|||
2ce17b0a3b |
|||
6d0ce573b3 |
|||
8e1dd686d3 |
20 changed files with 232 additions and 190 deletions
6
package-lock.json
generated
6
package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ace-builds": "^1.43.3",
|
||||||
"bootstrap": "^5.3.8"
|
"bootstrap": "^5.3.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
```
|
```
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,6 @@ 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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,7 +28,9 @@ 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 {
|
||||||
|
@ -37,7 +39,7 @@ func NewConfig() *Config {
|
||||||
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,
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -16,7 +16,7 @@ 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"}
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}}
|
|
@ -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') {
|
||||||
require(['vs/editor/editor.main'], function () {
|
editor.setTheme("ace/theme/github_light_default");
|
||||||
var editor = monaco.editor.create(document.getElementById('editor'), {
|
} else {
|
||||||
theme: document.documentElement.getAttribute('data-bs-theme') == 'light' ? "vs" : "vs-dark",
|
editor.setTheme("ace/theme/github_dark");
|
||||||
language: document.getElementById("language").value,
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Syntax highlighting
|
// Syntax highlighting
|
||||||
document.getElementById("language").addEventListener("change", (e) => {
|
document.getElementById("language").addEventListener("change", (e) => {
|
||||||
if (e.target.value != "") {
|
if (e.target.value != "") {
|
||||||
monaco.editor.setModelLanguage(editor.getModel(), e.target.value);
|
editor.getSession().setMode("ace/mode/" + e.target.value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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")
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Copy content on submit
|
// Copy content on submit
|
||||||
document.getElementById("form").addEventListener("formdata", (e) => {
|
document.getElementById("form").addEventListener("formdata", (e) => {
|
||||||
e.formData.append('content', editor.getModel().getValue());
|
e.formData.append('content', editor.getValue());
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
{{end}}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{block "footer" .}}{{end}}
|
{{block "footer" .}}{{end}}
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue