Compare commits
25 commits
Author | SHA1 | Date | |
---|---|---|---|
2c4ce556dc |
|||
121971210a | |||
020b50fb58 | |||
685914c323 |
|||
0ed61db444 |
|||
b35828d909 |
|||
ab6b03a6d4 |
|||
b0c0162b06 |
|||
888e2b3278 |
|||
5232b10a7c |
|||
b316c6ef67 |
|||
2d8d7efbcb |
|||
acfad88cb8 |
|||
de24146991 |
|||
f721e56371 |
|||
55de3afc71 |
|||
ee7b5f0c6e |
|||
1fcde736a8 |
|||
b5701b5a4d |
|||
9e0254c0b5 |
|||
61ca30690b |
|||
75bdab55df |
|||
8e1dd686d3 |
|||
634326190c |
|||
2c3ca08dbf |
29 changed files with 1190 additions and 471 deletions
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
|||
1.1.0
|
||||
1.3.1
|
||||
|
|
6
package-lock.json
generated
6
package-lock.json
generated
|
@ -5,6 +5,7 @@
|
|||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"ace-builds": "^1.43.3",
|
||||
"bootstrap": "^5.3.8"
|
||||
}
|
||||
},
|
||||
|
@ -18,6 +19,11 @@
|
|||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/ace-builds": {
|
||||
"version": "1.43.3",
|
||||
"resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.3.tgz",
|
||||
"integrity": "sha512-MCl9rALmXwIty/4Qboijo/yNysx1r6hBTzG+6n/TiOm5LFhZpEvEIcIITPFiEOEFDfgBOEmxu+a4f54LEFM6Sg=="
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"ace-builds": "^1.43.3",
|
||||
"bootstrap": "^5.3.8"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,17 +22,17 @@ Create from file:
|
|||
coller -file filename.txt
|
||||
```
|
||||
|
||||
Provide password for encryption:
|
||||
Provide encryption key:
|
||||
|
||||
```
|
||||
coller -ask-password
|
||||
coller -password PASSWORD
|
||||
coller -ask-encryption-key
|
||||
coller -encryption-key ENCRYPTION_KEY
|
||||
```
|
||||
|
||||
Create public note:
|
||||
Create a note in cleartext:
|
||||
|
||||
```
|
||||
coller -no-password
|
||||
coller -no-encryption
|
||||
```
|
||||
|
||||
Return the copier command to use client-side decryption instead of the URL:
|
||||
|
|
|
@ -13,9 +13,10 @@ import (
|
|||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"git.riou.xyz/jriou/coller/internal"
|
||||
"golang.design/x/clipboard"
|
||||
"golang.org/x/term"
|
||||
|
||||
"git.riou.xyz/jriou/coller/internal"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -31,12 +32,12 @@ type NotePayload struct {
|
|||
Expiration int `json:"expiration,omitempty"`
|
||||
DeleteAfterRead bool `json:"delete_after_read,omitempty"`
|
||||
Language string `json:"language"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type NoteResponse struct {
|
||||
ID string `json:"id"`
|
||||
ID int64 `json:"id"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
|
@ -60,13 +61,14 @@ func handleMain() int {
|
|||
quiet := flag.Bool("quiet", false, "Log errors only")
|
||||
verbose := flag.Bool("verbose", false, "Print more logs")
|
||||
debug := flag.Bool("debug", false, "Print even more logs")
|
||||
jsonFormat := flag.Bool("json", false, "Print logs in JSON format")
|
||||
configFile := flag.String("config", filepath.Join(homeDir, ".config", AppName+".json"), "Configuration file")
|
||||
reconfigure := flag.Bool("reconfigure", false, "Re-create configuration file")
|
||||
url := flag.String("url", "", "URL of the coller API")
|
||||
password := flag.String("password", os.Getenv("COLLER_PASSWORD"), "Password to encrypt the note")
|
||||
askPassword := flag.Bool("ask-password", false, "Read password from input")
|
||||
noPassword := flag.Bool("no-password", false, "Allow notes without password")
|
||||
passwordLength := flag.Int("password-length", 16, "Length of the auto-generated password")
|
||||
encryptionKey := flag.String("encryption-key", os.Getenv("COLLER_ENCRYPTION_KEY"), "Key to encrypt the note")
|
||||
askEncryptionKey := flag.Bool("ask-encryption-key", false, "Read encryption key from input")
|
||||
noEncryption := flag.Bool("no-encryption", false, "Allow notes without encryption key")
|
||||
encryptionKeyLength := flag.Int("encryption-key-length", 16, "Length of the auto-generated encryption key")
|
||||
flag.StringVar(&fileName, "file", "", "Read content of the note from a file")
|
||||
expiration := flag.Int("expiration", 0, "Number of seconds before expiration")
|
||||
deleteAfterRead := flag.Bool("delete-after-read", false, "Delete the note after the first read")
|
||||
|
@ -75,6 +77,7 @@ func handleMain() int {
|
|||
bearer := flag.String("bearer", os.Getenv("COLLER_BEARER"), "Bearer token")
|
||||
askBearer := flag.Bool("ask-bearer", false, "Read bearer token from input")
|
||||
language := flag.String("language", "", "Language of the note")
|
||||
password := flag.String("password", os.Getenv("COLLER_PASSWORD"), "Password to access the note")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
|
@ -100,7 +103,13 @@ func handleMain() int {
|
|||
if *quiet {
|
||||
level = slog.LevelError
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
|
||||
var logger *slog.Logger
|
||||
if *jsonFormat {
|
||||
logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
} else {
|
||||
logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
}
|
||||
|
||||
if *url == "" {
|
||||
if _, err = os.Stat(*configFile); errors.Is(err, os.ErrNotExist) || *reconfigure {
|
||||
|
@ -139,22 +148,22 @@ func handleMain() int {
|
|||
content = clipboard.Read(clipboard.FmtText)
|
||||
}
|
||||
|
||||
if *askPassword {
|
||||
fmt.Print("Password: ")
|
||||
if *askEncryptionKey {
|
||||
fmt.Print("Encryption key: ")
|
||||
p, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return internal.ReturnError(logger, "could not read password", err)
|
||||
return internal.ReturnError(logger, "could not read encryption key", err)
|
||||
}
|
||||
*password = string(p)
|
||||
*encryptionKey = string(p)
|
||||
fmt.Print("\n")
|
||||
}
|
||||
|
||||
if !*noPassword && *password == "" {
|
||||
logger.Debug("generating random password")
|
||||
if *passwordLength < internal.MIN_PASSWORD_LENGTH || *passwordLength > internal.MAX_PASSWORD_LENGTH {
|
||||
return internal.ReturnError(logger, "invalid password length for auto-generated password", fmt.Errorf("password length must be between %d and %d", internal.MIN_PASSWORD_LENGTH, internal.MAX_PASSWORD_LENGTH))
|
||||
if !*noEncryption && *encryptionKey == "" {
|
||||
logger.Debug("generating random encryption key")
|
||||
if *encryptionKeyLength < internal.MIN_ENCRYPTION_KEY_LENGTH || *encryptionKeyLength > internal.MAX_ENCRYPTION_KEY_LENGTH {
|
||||
return internal.ReturnError(logger, "invalid length of auto-generated encryption key", fmt.Errorf("encryption key length must be between %d and %d", internal.MIN_ENCRYPTION_KEY_LENGTH, internal.MAX_ENCRYPTION_KEY_LENGTH))
|
||||
}
|
||||
*password = internal.GenerateChars(*passwordLength)
|
||||
*encryptionKey = internal.GenerateChars(*encryptionKeyLength)
|
||||
}
|
||||
|
||||
if len(content) == 0 {
|
||||
|
@ -171,14 +180,17 @@ func handleMain() int {
|
|||
if *language != "" {
|
||||
p.Language = *language
|
||||
}
|
||||
|
||||
if *password != "" {
|
||||
logger.Debug("validating password")
|
||||
if err = internal.ValidatePassword(*password); err != nil {
|
||||
return internal.ReturnError(logger, "invalid password", nil)
|
||||
p.Password = *password
|
||||
}
|
||||
|
||||
if *encryptionKey != "" {
|
||||
logger.Debug("validating encryption key")
|
||||
if err = internal.ValidateEncryptionKey(*encryptionKey); err != nil {
|
||||
return internal.ReturnError(logger, "invalid encryption key", nil)
|
||||
}
|
||||
logger.Debug("encrypting content")
|
||||
content, err = internal.Encrypt(content, *password)
|
||||
content, err = internal.Encrypt(content, *encryptionKey)
|
||||
if err != nil {
|
||||
return internal.ReturnError(logger, "could not encrypt note", err)
|
||||
}
|
||||
|
@ -186,8 +198,12 @@ func handleMain() int {
|
|||
}
|
||||
|
||||
logger.Debug("encoding content")
|
||||
encoded := internal.Encode(content)
|
||||
p.Content = encoded
|
||||
encodedContent := internal.Encode(content)
|
||||
p.Content = encodedContent
|
||||
|
||||
logger.Debug("encoding password")
|
||||
encodedPassword := internal.Encode([]byte(*password))
|
||||
p.Password = encodedPassword
|
||||
|
||||
payload, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
|
@ -235,27 +251,28 @@ func handleMain() int {
|
|||
}
|
||||
|
||||
if r.StatusCode != http.StatusOK {
|
||||
return internal.ReturnError(logger, jsonBody.Message, fmt.Errorf("%s", jsonBody.Error))
|
||||
return internal.ReturnError(logger, jsonBody.Message, nil)
|
||||
}
|
||||
|
||||
logger.Debug("finding note location")
|
||||
var location string
|
||||
noteURL := *url + "/" + jsonBody.ID
|
||||
if *password != "" {
|
||||
if *copier {
|
||||
location = fmt.Sprintf("copier -password %s %s", *password, noteURL)
|
||||
} else {
|
||||
if *html {
|
||||
location = fmt.Sprintf("%s/%s.html", noteURL, *password)
|
||||
} else {
|
||||
location = fmt.Sprintf("%s/%s", noteURL, *password)
|
||||
}
|
||||
noteURL := *url + "/" + fmt.Sprintf("%d", jsonBody.ID)
|
||||
if *copier {
|
||||
location = "copier"
|
||||
if *password != "" {
|
||||
location += " -password '" + *password + "'"
|
||||
}
|
||||
location += " " + noteURL
|
||||
if *encryptionKey != "" {
|
||||
location += "#" + *encryptionKey
|
||||
}
|
||||
} else {
|
||||
location = noteURL
|
||||
if *html {
|
||||
location = fmt.Sprintf("%s.html", noteURL)
|
||||
} else {
|
||||
location = fmt.Sprintf("%s", noteURL)
|
||||
location += ".html"
|
||||
}
|
||||
if *encryptionKey != "" {
|
||||
location += "#" + *encryptionKey
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -18,8 +18,12 @@ The file format is **JSON**:
|
|||
* **title** (string): Title of the website
|
||||
* **database_type** (string): Type of the database (default "sqlite", "postgres" also supported)
|
||||
* **database_dsn** (string): Connection string for the database (default "collerd.db")
|
||||
* **id_length** (int): Number of characters for note identifiers (default 5)
|
||||
* **password_length** (int): Number of characters for generated passwords (default 16)
|
||||
* **node_id** (int): Number between 0 and 1023 to define the node generating identifiers (see [snowflake](https://github.com/bwmarrin/snowflake))
|
||||
* **encryption_key_length** (int): Number of characters for generated encryption key (default 16)
|
||||
* **allow_client_encryption_key** (bool): Allow encryption key provided by the client on the web UI
|
||||
* **allow_no_encryption** (bool): Allow notes without encryption
|
||||
* **enable_password_encryption** (bool): Enable password to protect notes (default true)
|
||||
* **enable_upload_file_button** (bool): Display the upload file button in the UI (default true)
|
||||
* **expiration_interval** (int): Number of seconds to wait between two expiration runs
|
||||
* **listen_address** (string): Address to listen for the web server (default "0.0.0.0")
|
||||
* **listen_port** (int): Port to listen for the web server (default 8080)
|
||||
|
@ -31,14 +35,17 @@ The file format is **JSON**:
|
|||
* **prometheus_route** (string): Route to expose Prometheus metrics (default "/metrics")
|
||||
* **prometheus_notes_metric** (string): Name of the notes count metric (default "collerd_notes")
|
||||
* **observation_internal** (int): Number of seconds to wait between two observations (default 60)
|
||||
* **languages** ([]string): List of supported [languages](https://github.com/microsoft/monaco-editor/tree/main/src/basic-languages)
|
||||
* **languages** ([]string): List of supported [languages](https://github.com/ajaxorg/ace/tree/master/src/mode)
|
||||
* **language** (string): Default language (default "text")
|
||||
* **enable_upload_file_button** (bool): Display the upload file button in the UI (default true)
|
||||
* **tls_cert_file** (string): Path to TLS certificate file to enable HTTPS
|
||||
* **tls_key_file** (string): Path to TLS key file to enable HTTPS
|
||||
* **ace_directory** (string): Serve [Ace](hhttps://ace.c9.io/) assets from this local directory (ex: "./node_modules/ace-builds"). See **Dependencies** for details.
|
||||
* **bootstrap_directory** (string): Serve [Bootstrap](https://getbootstrap.com/) assets from this local directory (ex: "./node_modules/bootstrap/dist"). See **Dependencies** for details.
|
||||
* **clients_base_directory** (string): Serve clients binaries from this local base directory (ex: "./releases"). The version will be append to the directory. Ignored if `show_version` is disabled.
|
||||
* **clients_base_url** (string): Define the base URL to download clients (default "https://git.riou.xyz/jriou/coller/releases/download"). The version (or "latest") will be append.
|
||||
* **disable_editor** (bool): Disable Ace editor.
|
||||
|
||||
The configuration file is not required but the service might not be exposed to the public.
|
||||
The configuration file is optional.
|
||||
|
||||
## API
|
||||
|
||||
|
@ -52,45 +59,63 @@ Create a note.
|
|||
|
||||
Body (JSON):
|
||||
* **content** (string): base64 encoded content (required)
|
||||
* **password** (string): use server-side encryption with this password
|
||||
* **encryption_key** (string): use server-side encryption with this encryption key
|
||||
* **encrypted** (bool): true if the content has been encrypted by the client
|
||||
* **expiration** (int): lifetime of the note in seconds (must be supported by the server)
|
||||
* **delete_after_read** (bool): delete the note after the first read
|
||||
* **language** (string): language of the note (must be supported by the server)
|
||||
|
||||
Response (JSON):
|
||||
* **id** (string): ID of the note
|
||||
* **id** (int): ID of the note
|
||||
|
||||
|
||||
### GET /\<id\>/\<password\>
|
||||
### GET /api/note/\<id\>/\<encryptionKey\>
|
||||
|
||||
> [!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\>
|
||||
### POST /api/note/\<id\>/\<encryptionKey\>
|
||||
|
||||
> [!WARNING]
|
||||
> Potential encryption key leak
|
||||
|
||||
Return content of a protected note encrypted by the given encryption key.
|
||||
|
||||
Body (JSON):
|
||||
* **password** (string): base64 encoded password used to protect the note (required)
|
||||
|
||||
### GET /api/note/\<id\>
|
||||
|
||||
Return content of a note.
|
||||
|
||||
If the note is encrypted, the encrypted value is returned (application/octet-stream). Otherwise, the text is returned (text/plain).
|
||||
|
||||
### POST /api/note/\<id\>
|
||||
|
||||
Return content of a protected note.
|
||||
|
||||
If the note is encrypted, the encrypted value is returned (application/octet-stream). Otherwise, the text is returned (text/plain).
|
||||
|
||||
Body (JSON):
|
||||
* **password** (string): base64 encoded password used to protect the note (required)
|
||||
|
||||
### Errors
|
||||
|
||||
Errors return **500 Server Internal Error** with the **JSON** payload:
|
||||
* **message** (string): context of the error
|
||||
* **error** (string): error message
|
||||
* **message** (string): message of the error
|
||||
|
||||
## Dependencies
|
||||
|
||||
The web interface depends on:
|
||||
|
||||
- [Ace](https://ace.c9.io/)
|
||||
- [Bootstrap](https://getbootstrap.com/)
|
||||
- [Monaco Editor](https://github.com/microsoft/monaco-editor/)
|
||||
|
||||
By default, those dependencies are fetched from **remote CDN** services by the client.
|
||||
|
||||
If you would like to download them to serve them locally:
|
||||
If you would like to download and serve them locally:
|
||||
|
||||
```
|
||||
npm install
|
||||
|
@ -106,8 +131,7 @@ Then configure the local directories:
|
|||
|
||||
```json
|
||||
{
|
||||
"ace_directory": "./node_modules/ace-builds",
|
||||
"bootstrap_directory": "./node_modules/bootstrap/dist"
|
||||
}
|
||||
```
|
||||
|
||||
Downloading Monaco Editor is not supported yet.
|
||||
```
|
|
@ -5,9 +5,10 @@ import (
|
|||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"git.riou.xyz/jriou/coller/internal"
|
||||
"git.riou.xyz/jriou/coller/server"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -26,6 +27,7 @@ func handleMain() int {
|
|||
quiet := flag.Bool("quiet", false, "Log errors only")
|
||||
verbose := flag.Bool("verbose", false, "Print more logs")
|
||||
debug := flag.Bool("debug", false, "Print even more logs")
|
||||
jsonFormat := flag.Bool("json", false, "Print logs in JSON format")
|
||||
configFileName := flag.String("config", "", "Configuration file name")
|
||||
|
||||
flag.Parse()
|
||||
|
@ -45,7 +47,13 @@ func handleMain() int {
|
|||
if *quiet {
|
||||
level = slog.LevelError
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
|
||||
var logger *slog.Logger
|
||||
if *jsonFormat {
|
||||
logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
} else {
|
||||
logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
}
|
||||
|
||||
if *configFileName != "" {
|
||||
err = internal.ReadConfig(*configFileName, config)
|
||||
|
@ -69,8 +77,7 @@ func handleMain() int {
|
|||
return internal.ReturnError(logger, "could not create server", err)
|
||||
}
|
||||
|
||||
srv.SetIDLength(config.IDLength)
|
||||
srv.SetPasswordLength(config.PasswordLength)
|
||||
srv.SetEncryptionKeyLength(config.EncryptionKeyLength)
|
||||
|
||||
if config.EnableMetrics {
|
||||
reg := prometheus.NewRegistry()
|
||||
|
|
|
@ -11,6 +11,6 @@ copier -help
|
|||
# Examples
|
||||
|
||||
```
|
||||
copier -password PASSWORD URL
|
||||
copier -ask-password URL
|
||||
copier -encryption-key ENCRYPTION_KEY URL
|
||||
copier -ask-encryption-key URL
|
||||
```
|
|
@ -1,16 +1,20 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"git.riou.xyz/jriou/coller/internal"
|
||||
"golang.org/x/term"
|
||||
|
||||
"git.riou.xyz/jriou/coller/internal"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -20,6 +24,15 @@ var (
|
|||
GitCommit string
|
||||
)
|
||||
|
||||
type NotePayload struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type NoteResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func handleMain() int {
|
||||
|
||||
flag.Usage = usage
|
||||
|
@ -28,11 +41,13 @@ func handleMain() int {
|
|||
quiet := flag.Bool("quiet", false, "Log errors only")
|
||||
verbose := flag.Bool("verbose", false, "Print more logs")
|
||||
debug := flag.Bool("debug", false, "Print even more logs")
|
||||
password := flag.String("password", os.Getenv("COLLER_PASSWORD"), "Password to decrypt the note")
|
||||
askPassword := flag.Bool("ask-password", false, "Read password from input")
|
||||
jsonFormat := flag.Bool("json", false, "Print logs in JSON format")
|
||||
encryptionKey := flag.String("encryption-key", os.Getenv("COLLER_ENCRYPTION_KEY"), "Key to decrypt the note")
|
||||
askEncryptionKey := flag.Bool("ask-encryption-key", false, "Read encryption key from input")
|
||||
fileName := flag.String("file", "", "Write content of the note to a file")
|
||||
bearer := flag.String("bearer", os.Getenv("COLLER_BEARER"), "Bearer token")
|
||||
askBearer := flag.Bool("ask-bearer", false, "Read bearer token from input")
|
||||
password := flag.String("password", os.Getenv("COLLER_PASSWORD"), "Password to access the note")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
|
@ -46,7 +61,7 @@ func handleMain() int {
|
|||
return internal.RC_ERROR
|
||||
}
|
||||
|
||||
url := flag.Args()[0]
|
||||
rawURL := flag.Args()[0]
|
||||
|
||||
var level slog.Level
|
||||
if *debug {
|
||||
|
@ -58,15 +73,21 @@ func handleMain() int {
|
|||
if *quiet {
|
||||
level = slog.LevelError
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
|
||||
if *askPassword {
|
||||
fmt.Print("Password: ")
|
||||
var logger *slog.Logger
|
||||
if *jsonFormat {
|
||||
logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
} else {
|
||||
logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
}
|
||||
|
||||
if *askEncryptionKey {
|
||||
fmt.Print("Encryption key: ")
|
||||
p, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return internal.ReturnError(logger, "could not read password", err)
|
||||
return internal.ReturnError(logger, "could not read encryption key", err)
|
||||
}
|
||||
*password = string(p)
|
||||
*encryptionKey = string(p)
|
||||
fmt.Print("\n")
|
||||
}
|
||||
|
||||
|
@ -80,16 +101,46 @@ func handleMain() int {
|
|||
fmt.Print("\n")
|
||||
}
|
||||
|
||||
logger.Debug("creating http request")
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
logger.Debug("parsing url", slog.Any("url", rawURL))
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return internal.ReturnError(logger, "could not create request", err)
|
||||
return internal.ReturnError(logger, "could not parse url", err)
|
||||
}
|
||||
u.Path = "api/note" + u.Path
|
||||
|
||||
if u.Fragment != "" {
|
||||
*encryptionKey = u.Fragment
|
||||
u.Fragment = ""
|
||||
}
|
||||
|
||||
rawURL = u.String()
|
||||
|
||||
logger.Debug("creating http request")
|
||||
var req *http.Request
|
||||
if *password != "" {
|
||||
body := &NotePayload{
|
||||
Password: internal.Encode([]byte(*password)),
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return internal.ReturnError(logger, "could not create note payload", err)
|
||||
}
|
||||
req, err = http.NewRequest("POST", rawURL, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return internal.ReturnError(logger, "could not create request", err)
|
||||
}
|
||||
} else {
|
||||
req, err = http.NewRequest("GET", rawURL, nil)
|
||||
if err != nil {
|
||||
return internal.ReturnError(logger, "could not create request", err)
|
||||
}
|
||||
}
|
||||
|
||||
if *bearer != "" {
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *bearer))
|
||||
}
|
||||
|
||||
logger.Debug("parsing url", slog.Any("url", url))
|
||||
logger.Debug("executing http request", slog.Any("method", req.Method), slog.Any("url", rawURL))
|
||||
r, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return internal.ReturnError(logger, "could not retreive note", err)
|
||||
|
@ -101,12 +152,21 @@ func handleMain() int {
|
|||
return internal.ReturnError(logger, "could not read response", err)
|
||||
}
|
||||
|
||||
var content []byte
|
||||
if *password != "" {
|
||||
logger.Debug("decrypting note")
|
||||
content, err = internal.Decrypt(body, *password)
|
||||
if r.StatusCode != http.StatusOK {
|
||||
jsonBody := &NoteResponse{}
|
||||
err = json.Unmarshal(body, jsonBody)
|
||||
if err != nil {
|
||||
return internal.ReturnError(logger, "could not decrypt paste", err)
|
||||
return internal.ReturnError(logger, "could not decode response", err)
|
||||
}
|
||||
return internal.ReturnError(logger, jsonBody.Message, nil)
|
||||
}
|
||||
|
||||
var content []byte
|
||||
if *encryptionKey != "" {
|
||||
logger.Debug("decrypting note")
|
||||
content, err = internal.Decrypt(body, *encryptionKey)
|
||||
if err != nil {
|
||||
return internal.ReturnError(logger, "could not decrypt note", err)
|
||||
}
|
||||
} else {
|
||||
content = body
|
||||
|
|
15
src/go.mod
15
src/go.mod
|
@ -1,15 +1,15 @@
|
|||
module git.riou.xyz/jriou/coller
|
||||
|
||||
go 1.24
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.6
|
||||
toolchain go1.24.7
|
||||
|
||||
require (
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/prometheus/client_golang v1.23.0
|
||||
golang.design/x/clipboard v0.7.1
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/term v0.34.0
|
||||
golang.org/x/crypto v0.42.0
|
||||
golang.org/x/term v0.35.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.30.0
|
||||
|
@ -17,6 +17,7 @@ require (
|
|||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bwmarrin/snowflake v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
|
@ -32,8 +33,8 @@ require (
|
|||
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect
|
||||
golang.org/x/image v0.28.0 // indirect
|
||||
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
)
|
||||
|
|
12
src/go.sum
12
src/go.sum
|
@ -1,5 +1,7 @@
|
|||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
@ -50,6 +52,8 @@ golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c=
|
|||
golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE=
|
||||
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
|
||||
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
|
@ -58,12 +62,20 @@ golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRN
|
|||
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
|
|
@ -19,21 +19,21 @@ const (
|
|||
|
||||
// NewCipher creates a cipher using XChaCha20-Poly1305
|
||||
// https://pkg.go.dev/golang.org/x/crypto/chacha20poly1305
|
||||
// A salt is required to derive the key from a password using argon
|
||||
func NewCipher(password string, salt []byte) (cipher.AEAD, error) {
|
||||
key := argon2.IDKey([]byte(password), salt, KeyTime, KeyMemory, KeyThreads, KeySize)
|
||||
// A salt is required to derive the key from an encryption key using argon
|
||||
func NewCipher(encryptionKey string, salt []byte) (cipher.AEAD, error) {
|
||||
key := argon2.IDKey([]byte(encryptionKey), salt, KeyTime, KeyMemory, KeyThreads, KeySize)
|
||||
return chacha20poly1305.NewX(key)
|
||||
}
|
||||
|
||||
// Encrypt to encrypt a plaintext with a password
|
||||
// Encrypt to encrypt a plaintext with an encryption key
|
||||
// Returns a byte slice with the generated salt, nonce and the ciphertext
|
||||
func Encrypt(plaintext []byte, password string) (result []byte, err error) {
|
||||
func Encrypt(plaintext []byte, encryptionKey string) (result []byte, err error) {
|
||||
salt := make([]byte, SaltSize)
|
||||
if n, err := rand.Read(salt); err != nil || n != SaltSize {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
aead, err := NewCipher(password, salt)
|
||||
aead, err := NewCipher(encryptionKey, salt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -53,15 +53,15 @@ func Encrypt(plaintext []byte, password string) (result []byte, err error) {
|
|||
return result, nil
|
||||
}
|
||||
|
||||
// Decrypt to decrypt a ciphertext with a password
|
||||
// Decrypt to decrypt a ciphertext with a encryption key
|
||||
// Returns the plaintext
|
||||
func Decrypt(ciphertext []byte, password string) ([]byte, error) {
|
||||
func Decrypt(ciphertext []byte, encryptionKey string) ([]byte, error) {
|
||||
if len(ciphertext) < SaltSize {
|
||||
return nil, fmt.Errorf("ciphertext is too short: cannot read salt")
|
||||
}
|
||||
salt := ciphertext[:SaltSize]
|
||||
|
||||
aead, err := NewCipher(password, salt)
|
||||
aead, err := NewCipher(encryptionKey, salt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -6,10 +6,10 @@ import (
|
|||
|
||||
func TestEncryptAndDecrypt(t *testing.T) {
|
||||
plaintext := "test"
|
||||
password := "test"
|
||||
wrongPassword := password + "wrong"
|
||||
encryptionKey := "test"
|
||||
wrongEncryptionKey := encryptionKey + "wrong"
|
||||
|
||||
ciphertext, err := Encrypt([]byte(plaintext), password)
|
||||
ciphertext, err := Encrypt([]byte(plaintext), encryptionKey)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error when encrypting: %v", err)
|
||||
return
|
||||
|
@ -20,7 +20,7 @@ func TestEncryptAndDecrypt(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
cleartext, err := Decrypt(ciphertext, password)
|
||||
cleartext, err := Decrypt(ciphertext, encryptionKey)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error when decrypting: %v", err)
|
||||
return
|
||||
|
@ -31,14 +31,14 @@ func TestEncryptAndDecrypt(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
if password == wrongPassword {
|
||||
t.Errorf("passwords must be different")
|
||||
if encryptionKey == wrongEncryptionKey {
|
||||
t.Errorf("encryption keys must be different")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = Decrypt(ciphertext, wrongPassword)
|
||||
_, err = Decrypt(ciphertext, wrongEncryptionKey)
|
||||
if err == nil {
|
||||
t.Errorf("expected error when decrypting with a wrong password, got none")
|
||||
t.Errorf("expected error when decrypting with a wrong encryption key, got none")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
package internal
|
||||
|
||||
const (
|
||||
RC_OK = 0
|
||||
RC_ERROR = 1
|
||||
MIN_PASSWORD_LENGTH = 16
|
||||
MAX_PASSWORD_LENGTH = 256
|
||||
RC_OK = 0
|
||||
RC_ERROR = 1
|
||||
MIN_ENCRYPTION_KEY_LENGTH = 16
|
||||
MAX_ENCRYPTION_KEY_LENGTH = 256
|
||||
)
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ReadConfig(file string, config interface{}) error {
|
||||
|
@ -57,13 +58,13 @@ func GenerateChars(n int) string {
|
|||
return string(b)
|
||||
}
|
||||
|
||||
// Passwords must be URL compatible and strong enough
|
||||
// Encryption key must be URL compatible and strong enough
|
||||
// Requiring only alphanumeric chars with a size between 16 and 256
|
||||
var passwordRegexp = regexp.MustCompile("^[a-zA-Z0-9]{16,256}$")
|
||||
var encryptionKeyRegexp = regexp.MustCompile("^[a-zA-Z0-9]{16,256}$")
|
||||
|
||||
func ValidatePassword(p string) error {
|
||||
if !passwordRegexp.MatchString(p) {
|
||||
return fmt.Errorf("password doesn't match '%s'", passwordRegexp)
|
||||
func ValidateEncryptionKey(p string) error {
|
||||
if !encryptionKeyRegexp.MatchString(p) {
|
||||
return fmt.Errorf("encryption key doesn't match '%s'", encryptionKeyRegexp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -102,6 +103,15 @@ func HumanDuration(i int) string {
|
|||
return fmt.Sprintf("%d %s", i, w)
|
||||
}
|
||||
|
||||
// TimeDiff to return the number of seconds between this time and now
|
||||
func TimeDiff(ts time.Time) int {
|
||||
diff := int(time.Since(ts).Seconds())
|
||||
if diff < 0 {
|
||||
return diff * -1
|
||||
}
|
||||
return diff
|
||||
}
|
||||
|
||||
func ReturnError(logger *slog.Logger, message string, err error) int {
|
||||
if err != nil {
|
||||
logger.Error(message, slog.Any("error", err))
|
||||
|
|
|
@ -7,40 +7,47 @@ import (
|
|||
)
|
||||
|
||||
type Config struct {
|
||||
Title string `json:"title"`
|
||||
DatabaseType string `json:"database_type"`
|
||||
DatabaseDsn string `json:"database_dsn"`
|
||||
IDLength int `json:"id_length"`
|
||||
PasswordLength int `json:"password_length"`
|
||||
ExpirationInterval int `json:"expiration_interval"`
|
||||
ListenAddress string `json:"listen_address"`
|
||||
ListenPort int `json:"listen_port"`
|
||||
Expirations []int `json:"expirations"`
|
||||
Expiration int `json:"expiration"`
|
||||
MaxUploadSize int64 `json:"max_upload_size"`
|
||||
ShowVersion bool `json:"show_version"`
|
||||
EnableMetrics bool `json:"enable_metrics"`
|
||||
PrometheusRoute string `json:"prometheus_route"`
|
||||
PrometheusNotesMetric string `json:"prometheus_notes_metric"`
|
||||
ObservationInterval int `json:"observation_internal"`
|
||||
Languages []string `json:"languages"`
|
||||
Language string `json:"language"`
|
||||
EnableUploadFileButton bool `json:"enable_upload_file_button"`
|
||||
TLSCertFile string `json:"tls_cert_file"`
|
||||
TLSKeyFile string `json:"tls_key_file"`
|
||||
BootstrapDirectory string `json:"bootstrap_directory"`
|
||||
Title string `json:"title"`
|
||||
DatabaseType string `json:"database_type"`
|
||||
DatabaseDsn string `json:"database_dsn"`
|
||||
NodeID int64 `json:"node_id"`
|
||||
EncryptionKeyLength int `json:"encryption_key_length"`
|
||||
AllowClientEncryptionKey bool `json:"allow_client_encryption_key"`
|
||||
AllowNoEncryption bool `json:"allow_no_encryption"`
|
||||
EnablePasswordProtection bool `json:"enable_password_protection"`
|
||||
EnableUploadFileButton bool `json:"enable_upload_file_button"`
|
||||
ExpirationInterval int `json:"expiration_interval"`
|
||||
ListenAddress string `json:"listen_address"`
|
||||
ListenPort int `json:"listen_port"`
|
||||
Expirations []int `json:"expirations"`
|
||||
Expiration int `json:"expiration"`
|
||||
MaxUploadSize int64 `json:"max_upload_size"`
|
||||
ShowVersion bool `json:"show_version"`
|
||||
EnableMetrics bool `json:"enable_metrics"`
|
||||
PrometheusRoute string `json:"prometheus_route"`
|
||||
PrometheusNotesMetric string `json:"prometheus_notes_metric"`
|
||||
ObservationInterval int `json:"observation_internal"`
|
||||
Languages []string `json:"languages"`
|
||||
Language string `json:"language"`
|
||||
TLSCertFile string `json:"tls_cert_file"`
|
||||
TLSKeyFile string `json:"tls_key_file"`
|
||||
AceDirectory string `json:"ace_directory"`
|
||||
BootstrapDirectory string `json:"bootstrap_directory"`
|
||||
DisableEditor bool `json:"disable_editor"`
|
||||
ClientsBaseURL string `json:"clients_base_url"`
|
||||
ClientsBaseDirectory string `json:"clients_base_directory"`
|
||||
}
|
||||
|
||||
func NewConfig() *Config {
|
||||
return &Config{
|
||||
Title: "Coller",
|
||||
DatabaseType: "sqlite",
|
||||
DatabaseDsn: "collerd.db",
|
||||
IDLength: 5,
|
||||
PasswordLength: 16,
|
||||
ExpirationInterval: 60, // 1 minute
|
||||
ListenAddress: "0.0.0.0",
|
||||
ListenPort: 8080,
|
||||
Title: "Coller",
|
||||
DatabaseType: "sqlite",
|
||||
DatabaseDsn: "collerd.db",
|
||||
NodeID: 1,
|
||||
EncryptionKeyLength: 16,
|
||||
ExpirationInterval: 60, // 1 minute
|
||||
ListenAddress: "0.0.0.0",
|
||||
ListenPort: 8080,
|
||||
Expirations: []int{
|
||||
300, // 5 minutes
|
||||
3600, // 1 hour
|
||||
|
@ -56,25 +63,26 @@ func NewConfig() *Config {
|
|||
PrometheusNotesMetric: "collerd_notes",
|
||||
ObservationInterval: 60,
|
||||
Languages: []string{
|
||||
"Text",
|
||||
"CSS",
|
||||
"Dockerfile",
|
||||
"Go",
|
||||
"HCL",
|
||||
"HTML",
|
||||
"Javascript",
|
||||
"JSON",
|
||||
"Markdown",
|
||||
"Perl",
|
||||
"Python",
|
||||
"Ruby",
|
||||
"Rust",
|
||||
"Shell",
|
||||
"SQL",
|
||||
"YAML",
|
||||
"css",
|
||||
"dockerfile",
|
||||
"golang",
|
||||
"html",
|
||||
"javascript",
|
||||
"json",
|
||||
"markdown",
|
||||
"perl",
|
||||
"python",
|
||||
"ruby",
|
||||
"rust",
|
||||
"sh",
|
||||
"sql",
|
||||
"text",
|
||||
"yaml",
|
||||
},
|
||||
Language: "text",
|
||||
EnableUploadFileButton: true,
|
||||
Language: "text",
|
||||
EnableUploadFileButton: true,
|
||||
EnablePasswordProtection: true,
|
||||
ClientsBaseURL: "https://git.riou.xyz/jriou/coller/releases/download",
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -88,12 +96,12 @@ func (c *Config) Check() error {
|
|||
}
|
||||
}
|
||||
|
||||
if c.IDLength <= 0 {
|
||||
return fmt.Errorf("identifiers length must be greater than zero")
|
||||
if c.NodeID < 0 || c.NodeID > 1023 {
|
||||
return fmt.Errorf("node id must be between 0 and 1023")
|
||||
}
|
||||
|
||||
if c.PasswordLength < internal.MIN_PASSWORD_LENGTH || c.PasswordLength > internal.MAX_PASSWORD_LENGTH {
|
||||
return fmt.Errorf("password length must be between %d and %d", internal.MIN_PASSWORD_LENGTH, internal.MAX_PASSWORD_LENGTH)
|
||||
if c.EncryptionKeyLength < internal.MIN_ENCRYPTION_KEY_LENGTH || c.EncryptionKeyLength > internal.MAX_ENCRYPTION_KEY_LENGTH {
|
||||
return fmt.Errorf("encryption key length must be between %d and %d", internal.MIN_ENCRYPTION_KEY_LENGTH, internal.MAX_ENCRYPTION_KEY_LENGTH)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -7,11 +7,14 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"git.riou.xyz/jriou/coller/internal"
|
||||
"github.com/bwmarrin/snowflake"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"git.riou.xyz/jriou/coller/internal"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
|
@ -22,6 +25,7 @@ type Database struct {
|
|||
expiration int
|
||||
languages []string
|
||||
language string
|
||||
node *snowflake.Node
|
||||
}
|
||||
|
||||
var gconfig = &gorm.Config{
|
||||
|
@ -48,6 +52,11 @@ func NewDatabase(logger *slog.Logger, config *Config) (d *Database, err error) {
|
|||
|
||||
logger.Debug("connected to the database")
|
||||
|
||||
node, err := snowflake.NewNode(config.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d = &Database{
|
||||
logger: l,
|
||||
db: db,
|
||||
|
@ -56,6 +65,7 @@ func NewDatabase(logger *slog.Logger, config *Config) (d *Database, err error) {
|
|||
expiration: config.Expiration,
|
||||
languages: internal.ToLowerStringSlice(config.Languages),
|
||||
language: strings.ToLower(config.Language),
|
||||
node: node,
|
||||
}
|
||||
|
||||
if err = d.UpdateSchema(); err != nil {
|
||||
|
@ -102,7 +112,7 @@ func (d *Database) Get(id string) (*Note, error) {
|
|||
d.logger.Warn("could not find note", slog.Any("error", trx.Error))
|
||||
return nil, trx.Error
|
||||
}
|
||||
if note.ID != "" {
|
||||
if note.ID != 0 {
|
||||
if note.DeleteAfterRead {
|
||||
if err := d.Delete(note.ID); err != nil {
|
||||
return nil, err
|
||||
|
@ -113,7 +123,7 @@ func (d *Database) Get(id string) (*Note, error) {
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
func (d *Database) Create(content []byte, password string, encrypted bool, expiration int, deleteAfterRead bool, language string) (note *Note, err error) {
|
||||
func (d *Database) Create(content []byte, password []byte, encryptionKey string, encrypted bool, expiration int, deleteAfterRead bool, language string) (note *Note, err error) {
|
||||
if expiration == 0 {
|
||||
expiration = d.expiration
|
||||
}
|
||||
|
@ -132,32 +142,44 @@ func (d *Database) Create(content []byte, password string, encrypted bool, expir
|
|||
}
|
||||
|
||||
note = &Note{
|
||||
ID: d.node.Generate().Int64(),
|
||||
Content: content,
|
||||
ExpiresAt: time.Now().Add(time.Duration(expiration) * time.Second),
|
||||
Encrypted: encrypted,
|
||||
DeleteAfterRead: deleteAfterRead,
|
||||
Language: language,
|
||||
}
|
||||
if password != "" {
|
||||
if err = internal.ValidatePassword(password); err != nil {
|
||||
|
||||
if encryptionKey != "" {
|
||||
if err = internal.ValidateEncryptionKey(encryptionKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
note.Content, err = internal.Encrypt(note.Content, password)
|
||||
note.Content, err = internal.Encrypt(note.Content, encryptionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
note.Encrypted = true
|
||||
}
|
||||
|
||||
if len(password) > 0 {
|
||||
hash, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
note.PasswordHash = hash
|
||||
}
|
||||
|
||||
trx := d.db.Create(note)
|
||||
defer trx.Commit()
|
||||
if trx.Error != nil {
|
||||
d.logger.Warn("could not create note", slog.Any("error", trx.Error))
|
||||
return nil, trx.Error
|
||||
}
|
||||
|
||||
return note, nil
|
||||
}
|
||||
|
||||
func (d *Database) Delete(id string) error {
|
||||
func (d *Database) Delete(id int64) error {
|
||||
trx := d.db.Where("id = ?", id).Delete(&Note{})
|
||||
defer trx.Commit()
|
||||
if trx.Error != nil {
|
||||
|
|
26
src/server/errors.go
Normal file
26
src/server/errors.go
Normal file
|
@ -0,0 +1,26 @@
|
|||
package server
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrCouldNotFindNote = errors.New("could not find note")
|
||||
ErrNoteDoesNotExist = errors.New("note does not exist")
|
||||
ErrCouldNotParseForm = errors.New("could not parse form")
|
||||
ErrEncryptionKeyNotFound = errors.New("encryption key not found")
|
||||
ErrCouldNotDecryptNote = errors.New("could not decrypt note")
|
||||
ErrInvalidPassword = errors.New("invalid password")
|
||||
ErrInvalidExpiration = errors.New("invalid expiration")
|
||||
ErrInvalidLanguage = errors.New("invalid language")
|
||||
ErrCouldNotParseFile = errors.New("could not parse file")
|
||||
ErrFileTooLarge = errors.New("file too large")
|
||||
ErrTextFileExpected = errors.New("text file expected")
|
||||
ErrCouldNotReadFile = errors.New("could not read file")
|
||||
ErrEmptyNote = errors.New("empty note")
|
||||
ErrEncryptionRequired = errors.New("encryption is required")
|
||||
ErrClientEncryptionKeyNotAllowed = errors.New("client encryption key is not allowed")
|
||||
ErrCouldNotCreateNote = errors.New("could not create note")
|
||||
ErrCouldNotDecodePayload = errors.New("could not decode payload")
|
||||
ErrCouldNotDecodeContent = errors.New("could not decode content")
|
||||
ErrCouldNotDecodePassword = errors.New("could not decode password")
|
||||
ErrNoteIsPasswordProtected = errors.New("note is password protected")
|
||||
)
|
|
@ -7,23 +7,71 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.riou.xyz/jriou/coller/internal"
|
||||
)
|
||||
|
||||
type APIErrorResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e APIErrorResponse) ToJSON() string {
|
||||
b, err := json.Marshal(e)
|
||||
if err == nil {
|
||||
return string(b)
|
||||
}
|
||||
return fmt.Sprintf("{\"message\":\"could not serialize response to JSON\", \"error\":\"%v\"}", err)
|
||||
}
|
||||
|
||||
func apiError(level int, w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) {
|
||||
// Wrap error for logging
|
||||
if err != nil {
|
||||
err = fmt.Errorf("%v: %w", topLevelErr, err)
|
||||
} else {
|
||||
err = topLevelErr
|
||||
}
|
||||
logger.Error(fmt.Sprintf("%v", err))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(level)
|
||||
fmt.Fprint(w, APIErrorResponse{
|
||||
Message: fmt.Sprintf("%v", topLevelErr),
|
||||
}.ToJSON())
|
||||
}
|
||||
|
||||
func APIError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) {
|
||||
apiError(http.StatusInternalServerError, w, logger, topLevelErr, err)
|
||||
}
|
||||
|
||||
func APIErrorNotFound(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) {
|
||||
apiError(http.StatusNotFound, w, logger, topLevelErr, err)
|
||||
}
|
||||
|
||||
func APIErrorBadRequest(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) {
|
||||
apiError(http.StatusBadRequest, w, logger, topLevelErr, err)
|
||||
}
|
||||
|
||||
func HealthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "OK")
|
||||
}
|
||||
|
||||
type CreateNoteHandler struct {
|
||||
logger *slog.Logger
|
||||
db *Database
|
||||
maxUploadSize int64
|
||||
logger *slog.Logger
|
||||
db *Database
|
||||
maxUploadSize int64
|
||||
allowClientEncryptionKey bool
|
||||
allowNoEncryption bool
|
||||
}
|
||||
|
||||
func (h *CreateNoteHandler) Name() string {
|
||||
return "CreateNoteHandler"
|
||||
}
|
||||
|
||||
type CreateNotePayload struct {
|
||||
Content string `json:"content"`
|
||||
Password string `json:"password"`
|
||||
EncryptionKey string `json:"encryption_key"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
Expiration int `json:"expiration"`
|
||||
DeleteAfterRead bool `json:"delete_after_read"`
|
||||
|
@ -31,32 +79,49 @@ type CreateNotePayload struct {
|
|||
}
|
||||
|
||||
type CreateNoteResponse struct {
|
||||
ID string `json:"id"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (h *CreateNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
logger := h.logger.With("handler", h.Name())
|
||||
|
||||
bodyReader := http.MaxBytesReader(w, r.Body, h.maxUploadSize)
|
||||
defer r.Body.Close()
|
||||
|
||||
var body CreateNotePayload
|
||||
err := json.NewDecoder(bodyReader).Decode(&body)
|
||||
if err != nil {
|
||||
WriteError(w, "could not decode payload to create note", err)
|
||||
APIError(w, logger, ErrCouldNotDecodePayload, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.allowNoEncryption && !body.Encrypted {
|
||||
APIError(w, logger, ErrEncryptionRequired, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.allowClientEncryptionKey && body.EncryptionKey != "" {
|
||||
APIError(w, logger, ErrClientEncryptionKeyNotAllowed, nil)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := internal.Decode(body.Content)
|
||||
|
||||
if err != nil {
|
||||
WriteError(w, "could not decode content", err)
|
||||
APIError(w, logger, ErrCouldNotDecodeContent, err)
|
||||
return
|
||||
}
|
||||
|
||||
note, err := h.db.Create(content, body.Password, body.Encrypted, body.Expiration, body.DeleteAfterRead, body.Language)
|
||||
password, err := internal.Decode(body.Password)
|
||||
if err != nil {
|
||||
WriteError(w, "could not create note", err)
|
||||
APIError(w, logger, ErrCouldNotDecodePassword, err)
|
||||
return
|
||||
}
|
||||
|
||||
note, err := h.db.Create(content, password, body.EncryptionKey, body.Encrypted, body.Expiration, body.DeleteAfterRead, body.Language)
|
||||
if err != nil {
|
||||
APIError(w, logger, ErrCouldNotCreateNote, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -69,6 +134,10 @@ type GetNoteHandler struct {
|
|||
db *Database
|
||||
}
|
||||
|
||||
func (h *GetNoteHandler) Name() string {
|
||||
return "GetNoteHandler"
|
||||
}
|
||||
|
||||
func (h *GetNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
|
||||
|
@ -76,10 +145,14 @@ func (h *GetNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
note, err := h.db.Get(id)
|
||||
|
||||
logger := h.logger.With("handler", h.Name(), "note_id", id)
|
||||
|
||||
if err != nil {
|
||||
WriteError(w, "could not get note", err)
|
||||
APIError(w, logger, ErrCouldNotFindNote, err)
|
||||
} else if note == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
APIErrorNotFound(w, logger, ErrNoteDoesNotExist, nil)
|
||||
} else if note.PasswordHash != nil {
|
||||
APIErrorBadRequest(w, logger, ErrNoteIsPasswordProtected, nil)
|
||||
} else {
|
||||
if note.Encrypted {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
|
@ -90,8 +163,18 @@ func (h *GetNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
type GetProtectedNoteHandler struct {
|
||||
logger *slog.Logger
|
||||
db *Database
|
||||
logger *slog.Logger
|
||||
db *Database
|
||||
maxUploadSize int64
|
||||
}
|
||||
|
||||
type GetProtectedNotePayload struct {
|
||||
EncryptionKey string `json:"encryption_key"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *GetProtectedNoteHandler) Name() string {
|
||||
return "GetProtectedNoteHandler"
|
||||
}
|
||||
|
||||
func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -99,22 +182,47 @@ func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
|||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
password := vars["password"]
|
||||
|
||||
logger := h.logger.With("handler", h.Name(), "note_id", id)
|
||||
|
||||
bodyReader := http.MaxBytesReader(w, r.Body, h.maxUploadSize)
|
||||
defer r.Body.Close()
|
||||
|
||||
var body GetProtectedNotePayload
|
||||
err := json.NewDecoder(bodyReader).Decode(&body)
|
||||
if err != nil {
|
||||
APIError(w, logger, ErrCouldNotDecodePayload, err)
|
||||
return
|
||||
}
|
||||
|
||||
note, err := h.db.Get(id)
|
||||
|
||||
if err != nil {
|
||||
WriteError(w, "could not get note", err)
|
||||
APIError(w, logger, ErrCouldNotFindNote, err)
|
||||
return
|
||||
} else if note == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if password != "" && note.Encrypted {
|
||||
note.Content, err = internal.Decrypt(note.Content, password)
|
||||
if body.EncryptionKey != "" && note.Encrypted {
|
||||
note.Content, err = internal.Decrypt(note.Content, body.EncryptionKey)
|
||||
if err != nil {
|
||||
WriteError(w, "could not decrypt note", err)
|
||||
APIError(w, logger, ErrCouldNotDecryptNote, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
password, err := internal.Decode(body.Password)
|
||||
if err != nil {
|
||||
APIError(w, logger, ErrCouldNotDecodePassword, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(note.PasswordHash) > 0 {
|
||||
err := bcrypt.CompareHashAndPassword(note.PasswordHash, password)
|
||||
if err != nil {
|
||||
APIErrorBadRequest(w, logger, ErrInvalidPassword, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -124,8 +232,10 @@ func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
|||
}
|
||||
|
||||
type ClientHandler struct {
|
||||
logger *slog.Logger
|
||||
version string
|
||||
logger *slog.Logger
|
||||
version string
|
||||
baseURL string
|
||||
baseDirectory string
|
||||
}
|
||||
|
||||
func (h *ClientHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -140,10 +250,19 @@ func (h *ClientHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
version := h.version
|
||||
if version == "" {
|
||||
version = "latest"
|
||||
// No disclosure of the version running on the server
|
||||
if h.version == "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/%s/%s-%s-%s", h.baseURL, "latest", clientName, os, arch), http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("https://git.riou.xyz/jriou/%s/releases/download/%s/%s-%s-%s", clientName, version, clientName, os, arch), http.StatusMovedPermanently)
|
||||
if h.baseDirectory != "" {
|
||||
// Serve file locally
|
||||
// Example: ./releases/1.2.0/coller-linux-amd64
|
||||
http.ServeFile(w, r, fmt.Sprintf("%s/%s/%s-%s-%s", h.baseDirectory, h.version, clientName, os, arch))
|
||||
} else {
|
||||
// Redirect to a download link
|
||||
// Example: https://git.riou.xyz/jriou/coller/releases/download/1.2.0/coller-linux-amd64
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/%s/%s-%s-%s", h.baseURL, h.version, clientName, os, arch), http.StatusMovedPermanently)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,21 +12,43 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.riou.xyz/jriou/coller/internal"
|
||||
)
|
||||
|
||||
type PageData struct {
|
||||
Title string
|
||||
Version string
|
||||
Expirations []int
|
||||
Expiration int
|
||||
Languages []string
|
||||
Err error
|
||||
URL string
|
||||
Note *Note
|
||||
EnableUploadFileButton bool
|
||||
BootstrapDirectory string
|
||||
Title string
|
||||
Version string
|
||||
Expirations []int
|
||||
Expiration int
|
||||
Languages []string
|
||||
Language string
|
||||
Err error
|
||||
URL string
|
||||
Note *Note
|
||||
EnablePasswordProtection bool
|
||||
EnableUploadFileButton bool
|
||||
AllowClientEncryptionKey bool
|
||||
AllowNoEncryption bool
|
||||
AceDirectory string
|
||||
BootstrapDirectory string
|
||||
DisableEditor bool
|
||||
Password string // Not stored in the database
|
||||
}
|
||||
|
||||
func WebError(w http.ResponseWriter, pageData PageData, templates *template.Template, templateName string, logger *slog.Logger, topLevelErr error, err error) {
|
||||
// Only show the top-level error to users
|
||||
pageData.Err = topLevelErr
|
||||
|
||||
// Show full error in the logs
|
||||
if err != nil {
|
||||
err = fmt.Errorf("%v: %w", topLevelErr, err)
|
||||
} else {
|
||||
err = pageData.Err
|
||||
}
|
||||
logger.Error(fmt.Sprintf("%v", err))
|
||||
templates.ExecuteTemplate(w, templateName, pageData)
|
||||
}
|
||||
|
||||
type HomeHandler struct {
|
||||
|
@ -46,109 +68,264 @@ type CreateNoteWithFormHandler struct {
|
|||
maxUploadSize int64
|
||||
}
|
||||
|
||||
func (h *CreateNoteWithFormHandler) TemplateName() string {
|
||||
return "create"
|
||||
}
|
||||
|
||||
func (h *CreateNoteWithFormHandler) Name() string {
|
||||
return "CreateNoteWithFormHandler"
|
||||
}
|
||||
|
||||
func (h *CreateNoteWithFormHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) {
|
||||
WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err)
|
||||
}
|
||||
|
||||
func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.PageData.Err = nil
|
||||
templateName := "create"
|
||||
|
||||
h.logger.Debug("parsing multipart form")
|
||||
logger := h.logger.With("handler", h.Name())
|
||||
|
||||
logger.Debug("parsing multipart form")
|
||||
err := r.ParseMultipartForm(h.maxUploadSize)
|
||||
if err != nil {
|
||||
h.PageData.Err = err
|
||||
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
|
||||
h.WebError(w, logger, ErrCouldNotParseForm, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("parsing content")
|
||||
logger.Debug("parsing content")
|
||||
content := []byte(r.FormValue("content"))
|
||||
|
||||
h.logger.Debug("parsing file")
|
||||
logger.Debug("parsing file")
|
||||
file, handler, err := r.FormFile("file")
|
||||
if err != nil && !errors.Is(err, http.ErrMissingFile) {
|
||||
h.PageData.Err = err
|
||||
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
|
||||
h.WebError(w, logger, ErrCouldNotParseFile, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !errors.Is(err, http.ErrMissingFile) {
|
||||
defer file.Close()
|
||||
|
||||
h.logger.Debug("checking file size")
|
||||
logger.Debug("checking file size")
|
||||
if handler.Size > h.maxUploadSize {
|
||||
h.PageData.Err = fmt.Errorf("file too large (%d > %d)", handler.Size, h.maxUploadSize)
|
||||
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
|
||||
h.WebError(w, logger, ErrFileTooLarge, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("checking file content type")
|
||||
logger.Debug("checking file content type")
|
||||
if !strings.HasPrefix(handler.Header.Get("Content-Type"), "text/") {
|
||||
h.PageData.Err = fmt.Errorf("text file expected (got %s)", handler.Header.Get("Content-Type"))
|
||||
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
|
||||
h.WebError(w, logger, ErrTextFileExpected, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("reading uploaded file")
|
||||
logger.Debug("reading uploaded file")
|
||||
var fileContent bytes.Buffer
|
||||
n, err := io.Copy(&fileContent, file)
|
||||
if err != nil {
|
||||
h.PageData.Err = err
|
||||
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
|
||||
h.WebError(w, logger, ErrCouldNotReadFile, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("file uploaded", slog.Any("bytes", n))
|
||||
logger.Debug("file uploaded", slog.Any("bytes", n))
|
||||
if n != 0 {
|
||||
content = fileContent.Bytes()
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Debug("checking content")
|
||||
logger.Debug("checking content")
|
||||
if content == nil || len(content) == 0 {
|
||||
h.PageData.Err = fmt.Errorf("empty note")
|
||||
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
|
||||
h.WebError(w, logger, ErrEmptyNote, nil)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("checking inputs")
|
||||
noPassword := r.FormValue("no-password")
|
||||
logger.Debug("checking inputs")
|
||||
password := r.FormValue("password")
|
||||
noEncryption := r.FormValue("no-encryption")
|
||||
encryptionKey := r.FormValue("encryption-key")
|
||||
expiration := r.FormValue("expiration")
|
||||
deleteAfterRead := r.FormValue("delete-after-read")
|
||||
language := r.FormValue("language")
|
||||
|
||||
if password == "" && noPassword == "" {
|
||||
h.logger.Debug("generating password")
|
||||
password = internal.GenerateChars(passwordLength)
|
||||
if !h.PageData.AllowNoEncryption && noEncryption != "" {
|
||||
h.WebError(w, logger, ErrEncryptionRequired, nil)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("computing expiration")
|
||||
if !h.PageData.AllowClientEncryptionKey && encryptionKey != "" {
|
||||
h.WebError(w, logger, ErrClientEncryptionKeyNotAllowed, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.PageData.AllowClientEncryptionKey && encryptionKey == "" && noEncryption == "" {
|
||||
logger.Debug("generating encryption key")
|
||||
encryptionKey = internal.GenerateChars(encryptionKeyLength)
|
||||
}
|
||||
|
||||
logger.Debug("computing expiration")
|
||||
var expirationInt int
|
||||
if expiration == "Expiration" {
|
||||
expirationInt = 0
|
||||
} else {
|
||||
expirationInt, _ = strconv.Atoi(expiration)
|
||||
expirationInt, err = strconv.Atoi(expiration)
|
||||
if err != nil {
|
||||
h.WebError(w, logger, ErrInvalidExpiration, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Debug("saving note to the database")
|
||||
note, err := h.db.Create(content, password, password != "", expirationInt, deleteAfterRead != "", language)
|
||||
logger.Debug("saving note to the database")
|
||||
note, err := h.db.Create(content, []byte(password), encryptionKey, encryptionKey != "", expirationInt, deleteAfterRead != "", language)
|
||||
if err != nil {
|
||||
h.PageData.Err = err
|
||||
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
|
||||
h.WebError(w, logger, ErrCouldNotCreateNote, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("building note url")
|
||||
logger.Debug("building note url")
|
||||
|
||||
var scheme = "http://"
|
||||
if r.TLS != nil {
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto + "://"
|
||||
} else if r.TLS != nil {
|
||||
scheme = "https://"
|
||||
}
|
||||
|
||||
h.PageData.URL = fmt.Sprintf("%s%s/%s", scheme, r.Host, note.ID)
|
||||
if password != "" {
|
||||
h.PageData.URL += "/" + password
|
||||
h.PageData.URL = fmt.Sprintf("%s%s/%d.html", scheme, r.Host, note.ID)
|
||||
if encryptionKey != "" {
|
||||
h.PageData.URL += "#" + encryptionKey
|
||||
}
|
||||
|
||||
h.logger.Debug("rendering page")
|
||||
h.Templates.ExecuteTemplate(w, "create", h.PageData)
|
||||
logger.Debug("rendering page")
|
||||
h.Templates.ExecuteTemplate(w, h.TemplateName(), h.PageData)
|
||||
}
|
||||
|
||||
type GetRawWebNoteHandler struct {
|
||||
Templates *template.Template
|
||||
PageData PageData
|
||||
logger *slog.Logger
|
||||
db *Database
|
||||
}
|
||||
|
||||
func (h *GetRawWebNoteHandler) TemplateName() string {
|
||||
return "unprotectedNote"
|
||||
}
|
||||
|
||||
func (h *GetRawWebNoteHandler) Name() string {
|
||||
return "GetRawWebNoteHandler"
|
||||
}
|
||||
|
||||
func (h *GetRawWebNoteHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) {
|
||||
WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err)
|
||||
}
|
||||
|
||||
func (h *GetRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.PageData.Err = nil
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
logger := h.logger.With("handler", h.Name(), "note_id", id)
|
||||
|
||||
logger.Debug("fetching note from the database")
|
||||
note, err := h.db.Get(id)
|
||||
|
||||
if err != nil {
|
||||
h.WebError(w, logger, ErrCouldNotFindNote, err)
|
||||
return
|
||||
}
|
||||
|
||||
if note == nil {
|
||||
h.WebError(w, logger, ErrNoteDoesNotExist, err)
|
||||
return
|
||||
}
|
||||
|
||||
if note.Encrypted || len(note.PasswordHash) > 0 {
|
||||
logger.Debug("rendering page")
|
||||
h.PageData.Note = note
|
||||
h.Templates.ExecuteTemplate(w, h.TemplateName(), h.PageData)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("returning content")
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, string(note.Content))
|
||||
}
|
||||
|
||||
type GetProtectedRawWebNoteHandler struct {
|
||||
Templates *template.Template
|
||||
PageData PageData
|
||||
logger *slog.Logger
|
||||
db *Database
|
||||
maxUploadSize int64
|
||||
}
|
||||
|
||||
func (h *GetProtectedRawWebNoteHandler) TemplateName() string {
|
||||
return "protectedNote"
|
||||
}
|
||||
|
||||
func (h *GetProtectedRawWebNoteHandler) Name() string {
|
||||
return "GetProtectedRawWebNoteHandler"
|
||||
}
|
||||
|
||||
func (h *GetProtectedRawWebNoteHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) {
|
||||
WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err)
|
||||
}
|
||||
|
||||
func (h *GetProtectedRawWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.PageData.Err = nil
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
logger := h.logger.With("handler", h.Name(), "note_id", id)
|
||||
|
||||
logger.Debug("parsing multipart form")
|
||||
err := r.ParseMultipartForm(h.maxUploadSize)
|
||||
if err != nil {
|
||||
h.WebError(w, logger, ErrCouldNotParseForm, err)
|
||||
return
|
||||
}
|
||||
|
||||
password := r.FormValue("password")
|
||||
encryptionKey := r.FormValue("encryption-key")
|
||||
|
||||
logger.Debug("fetching note from the database")
|
||||
note, err := h.db.Get(id)
|
||||
|
||||
if err != nil {
|
||||
h.WebError(w, logger, ErrCouldNotFindNote, err)
|
||||
return
|
||||
}
|
||||
|
||||
if note == nil {
|
||||
h.WebError(w, logger, ErrNoteDoesNotExist, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if note.Encrypted {
|
||||
if encryptionKey == "" {
|
||||
h.WebError(w, logger, ErrEncryptionKeyNotFound, nil)
|
||||
return
|
||||
}
|
||||
logger.Debug("decrypting content")
|
||||
note.Content, err = internal.Decrypt(note.Content, encryptionKey)
|
||||
if err != nil {
|
||||
h.WebError(w, logger, ErrCouldNotDecryptNote, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(note.PasswordHash) > 0 {
|
||||
logger.Debug("comparing password hashes")
|
||||
if err := bcrypt.CompareHashAndPassword(note.PasswordHash, []byte(password)); err != nil {
|
||||
h.WebError(w, logger, ErrInvalidPassword, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug("returning content")
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, string(note.Content))
|
||||
}
|
||||
|
||||
type GetWebNoteHandler struct {
|
||||
|
@ -158,74 +335,124 @@ type GetWebNoteHandler struct {
|
|||
db *Database
|
||||
}
|
||||
|
||||
func (h *GetWebNoteHandler) TemplateName() string {
|
||||
return "unprotectedNote"
|
||||
}
|
||||
|
||||
func (h *GetWebNoteHandler) Name() string {
|
||||
return "GetWebNoteHandler"
|
||||
}
|
||||
|
||||
func (h *GetWebNoteHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) {
|
||||
WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err)
|
||||
}
|
||||
|
||||
func (h *GetWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.PageData.Err = nil
|
||||
templateName := "note"
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
logger := h.logger.With("handler", h.Name(), "note_id", id)
|
||||
|
||||
note, err := h.db.Get(id)
|
||||
|
||||
if err != nil {
|
||||
h.PageData.Err = fmt.Errorf("could not find note: %v", err)
|
||||
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
|
||||
h.WebError(w, logger, ErrCouldNotFindNote, err)
|
||||
return
|
||||
}
|
||||
|
||||
if note == nil {
|
||||
h.PageData.Err = fmt.Errorf("note doesn't exist or has been deleted")
|
||||
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
|
||||
return
|
||||
}
|
||||
|
||||
if note.Encrypted {
|
||||
h.PageData.Err = fmt.Errorf("note is encrypted")
|
||||
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
|
||||
h.WebError(w, logger, ErrNoteDoesNotExist, nil)
|
||||
return
|
||||
}
|
||||
|
||||
h.PageData.Note = note
|
||||
|
||||
h.logger.Debug("rendering note web page")
|
||||
h.Templates.ExecuteTemplate(w, "note", h.PageData)
|
||||
logger.Debug("rendering page")
|
||||
h.Templates.ExecuteTemplate(w, h.TemplateName(), h.PageData)
|
||||
}
|
||||
|
||||
type GetProtectedWebNoteHandler struct {
|
||||
Templates *template.Template
|
||||
PageData PageData
|
||||
logger *slog.Logger
|
||||
db *Database
|
||||
maxUploadSize int64
|
||||
}
|
||||
|
||||
func (h *GetProtectedWebNoteHandler) TemplateName() string {
|
||||
return "protectedNote"
|
||||
}
|
||||
|
||||
func (h *GetProtectedWebNoteHandler) Name() string {
|
||||
return "GetProtectedWebNoteHandler"
|
||||
}
|
||||
|
||||
func (h *GetProtectedWebNoteHandler) WebError(w http.ResponseWriter, logger *slog.Logger, topLevelErr error, err error) {
|
||||
WebError(w, h.PageData, h.Templates, h.TemplateName(), logger, topLevelErr, err)
|
||||
}
|
||||
|
||||
func (h *GetProtectedWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.PageData.Err = nil
|
||||
templateName := "note"
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
password := vars["password"]
|
||||
|
||||
logger := h.logger.With("handler", h.Name(), "note_id", id)
|
||||
|
||||
logger.Debug("parsing multipart form")
|
||||
err := r.ParseMultipartForm(h.maxUploadSize)
|
||||
if err != nil {
|
||||
h.WebError(w, logger, ErrCouldNotParseForm, err)
|
||||
return
|
||||
}
|
||||
|
||||
password := r.FormValue("password")
|
||||
encryptionKey := r.FormValue("encryption-key")
|
||||
|
||||
note, err := h.db.Get(id)
|
||||
|
||||
if err != nil {
|
||||
h.PageData.Err = fmt.Errorf("could not find note: %v", err)
|
||||
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
|
||||
h.WebError(w, logger, ErrCouldNotFindNote, err)
|
||||
return
|
||||
}
|
||||
|
||||
if note == nil {
|
||||
h.PageData.Err = fmt.Errorf("note doesn't exist or has been deleted")
|
||||
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
|
||||
h.WebError(w, logger, ErrNoteDoesNotExist, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if password != "" && note.Encrypted {
|
||||
note.Content, err = internal.Decrypt(note.Content, password)
|
||||
if note.Encrypted {
|
||||
if encryptionKey == "" {
|
||||
h.WebError(w, logger, ErrEncryptionKeyNotFound, nil)
|
||||
return
|
||||
}
|
||||
note.Content, err = internal.Decrypt(note.Content, encryptionKey)
|
||||
if err != nil {
|
||||
h.PageData.Err = fmt.Errorf("could not decrypt note: %v", err)
|
||||
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
|
||||
h.WebError(w, logger, ErrCouldNotDecryptNote, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(note.PasswordHash) > 0 {
|
||||
if err := bcrypt.CompareHashAndPassword(note.PasswordHash, []byte(password)); err != nil {
|
||||
h.WebError(w, logger, ErrInvalidPassword, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.PageData.Password = password
|
||||
h.PageData.Note = note
|
||||
|
||||
h.logger.Debug("rendering protected note web page")
|
||||
h.Templates.ExecuteTemplate(w, "note", h.PageData)
|
||||
logger.Debug("rendering page")
|
||||
h.Templates.ExecuteTemplate(w, h.TemplateName(), h.PageData)
|
||||
}
|
||||
|
||||
type ClientsHandler struct {
|
||||
Templates *template.Template
|
||||
PageData PageData
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h *ClientsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
@ -1,47 +1,23 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.riou.xyz/jriou/coller/internal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const ID_MAX_RETRIES = 10
|
||||
|
||||
var idLength = 5
|
||||
|
||||
type Note struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
ID int64 `json:"id" gorm:"primaryKey"`
|
||||
Content []byte `json:"content" gorm:"not null"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
PasswordHash []byte `json:"password_hash"`
|
||||
ExpiresAt time.Time `json:"expires_at" gorm:"index"`
|
||||
DeleteAfterRead bool `json:"delete_after_read"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
// Generate ID and compress content before saving to the database
|
||||
// Compress content before saving to the database
|
||||
func (n *Note) BeforeCreate(trx *gorm.DB) (err error) {
|
||||
for i := 0; i < ID_MAX_RETRIES; i++ {
|
||||
if n.ID != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
id := internal.GenerateChars(idLength)
|
||||
|
||||
var note Note
|
||||
trx.Where("id = ?", id).Find(¬e)
|
||||
|
||||
if note.ID == "" {
|
||||
n.ID = id
|
||||
continue
|
||||
}
|
||||
}
|
||||
if n.ID == "" {
|
||||
return fmt.Errorf("could not find unique id before creating the note")
|
||||
}
|
||||
|
||||
n.Content = Compress(n.Content)
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ package server
|
|||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
|
@ -16,10 +15,10 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
passwordLength = internal.MIN_PASSWORD_LENGTH
|
||||
supportedOSes = []string{"linux", "darwin"}
|
||||
supportedArches = []string{"amd64", "arm64"}
|
||||
supportedClients = []string{"coller", "copier"}
|
||||
encryptionKeyLength = internal.MIN_ENCRYPTION_KEY_LENGTH
|
||||
supportedOSes = []string{"linux", "darwin"}
|
||||
supportedArches = []string{"amd64", "arm64"}
|
||||
supportedClients = []string{"coller", "copier"}
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
|
@ -41,54 +40,15 @@ func NewServer(logger *slog.Logger, db *Database, config *Config, version string
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) SetIDLength(length int) {
|
||||
idLength = length
|
||||
}
|
||||
|
||||
func (s *Server) SetPasswordLength(length int) {
|
||||
passwordLength = length
|
||||
func (s *Server) SetEncryptionKeyLength(length int) {
|
||||
encryptionKeyLength = length
|
||||
}
|
||||
|
||||
func (s *Server) SetMetrics(metrics *Metrics) {
|
||||
s.metrics = metrics
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (e ErrorResponse) ToJSON() string {
|
||||
b, err := json.Marshal(e)
|
||||
if err == nil {
|
||||
return string(b)
|
||||
}
|
||||
return fmt.Sprintf("{\"message\":\"could not serialize response to JSON\", \"error\":\"%v\"}", err)
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, message string, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprint(w, ErrorResponse{
|
||||
Message: message,
|
||||
Error: fmt.Sprintf("%v", err),
|
||||
}.ToJSON())
|
||||
}
|
||||
|
||||
type GetProtectedWebNoteHandler struct {
|
||||
Templates *template.Template
|
||||
PageData PageData
|
||||
logger *slog.Logger
|
||||
db *Database
|
||||
}
|
||||
|
||||
type ClientsHandler struct {
|
||||
Templates *template.Template
|
||||
PageData PageData
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
//go:embed templates/*
|
||||
//go:embed templates/*.html
|
||||
var templatesFS embed.FS
|
||||
|
||||
func (s *Server) Start() error {
|
||||
|
@ -103,33 +63,59 @@ func (s *Server) Start() error {
|
|||
}
|
||||
|
||||
// API
|
||||
r.Path("/api/note").Handler(&CreateNoteHandler{logger: s.logger, db: s.db, maxUploadSize: s.config.MaxUploadSize}).Methods("POST")
|
||||
r.Path("/{id:[a-zA-Z0-9]+}/{password:[a-zA-Z0-9]+}").Handler(&GetProtectedNoteHandler{logger: s.logger, db: s.db}).Methods("GET")
|
||||
r.Path("/{id:[a-zA-Z0-9]+}").Handler(&GetNoteHandler{logger: s.logger, db: s.db}).Methods("GET")
|
||||
createNoteHandler := &CreateNoteHandler{
|
||||
logger: s.logger,
|
||||
db: s.db,
|
||||
maxUploadSize: s.config.MaxUploadSize,
|
||||
allowClientEncryptionKey: s.config.AllowClientEncryptionKey,
|
||||
allowNoEncryption: s.config.AllowNoEncryption,
|
||||
}
|
||||
r.Path("/api/note").Handler(createNoteHandler).Methods("POST")
|
||||
|
||||
getProtectedNoteHandler := &GetProtectedNoteHandler{
|
||||
logger: s.logger,
|
||||
db: s.db,
|
||||
maxUploadSize: s.config.MaxUploadSize,
|
||||
}
|
||||
r.Path("/api/note/{id:[a-zA-Z0-9]+}").Handler(getProtectedNoteHandler).Methods("POST")
|
||||
|
||||
getNoteHandler := &GetNoteHandler{
|
||||
logger: s.logger,
|
||||
db: s.db,
|
||||
}
|
||||
r.Path("/api/note/{id:[a-zA-Z0-9]+}").Handler(getNoteHandler).Methods("GET")
|
||||
|
||||
// Web pages
|
||||
funcs := template.FuncMap{
|
||||
"HumanDuration": internal.HumanDuration,
|
||||
"TimeDiff": internal.TimeDiff,
|
||||
"lower": strings.ToLower,
|
||||
"string": func(b []byte) string { return string(b) },
|
||||
}
|
||||
p := PageData{
|
||||
Title: s.config.Title,
|
||||
Expirations: s.config.Expirations,
|
||||
Expiration: s.config.Expiration,
|
||||
Languages: s.config.Languages,
|
||||
BootstrapDirectory: s.config.BootstrapDirectory,
|
||||
Title: s.config.Title,
|
||||
Expirations: s.config.Expirations,
|
||||
Expiration: s.config.Expiration,
|
||||
Languages: s.config.Languages,
|
||||
Language: s.config.Language,
|
||||
AceDirectory: s.config.AceDirectory,
|
||||
DisableEditor: s.config.DisableEditor,
|
||||
BootstrapDirectory: s.config.BootstrapDirectory,
|
||||
EnableUploadFileButton: s.config.EnableUploadFileButton,
|
||||
EnablePasswordProtection: s.config.EnablePasswordProtection,
|
||||
AllowClientEncryptionKey: s.config.AllowClientEncryptionKey,
|
||||
AllowNoEncryption: s.config.AllowNoEncryption,
|
||||
}
|
||||
|
||||
if s.config.ShowVersion {
|
||||
p.Version = s.version
|
||||
}
|
||||
p.EnableUploadFileButton = s.config.EnableUploadFileButton
|
||||
|
||||
templates, err := template.New("templates").Funcs(funcs).ParseFS(templatesFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
createNoteWithFormHandler := &CreateNoteWithFormHandler{
|
||||
Templates: templates,
|
||||
PageData: p,
|
||||
|
@ -145,15 +131,14 @@ func (s *Server) Start() error {
|
|||
logger: s.logger,
|
||||
}
|
||||
r.Path("/clients.html").Handler(clientsHandler).Methods("GET")
|
||||
r.Path("/clients/{os:[a-z]+}-{arch:[a-z0-9]+}/{clientName:[a-z]+}").Handler(&ClientHandler{logger: s.logger, version: p.Version}).Methods("GET")
|
||||
|
||||
protectedWebNoteHandler := &GetProtectedWebNoteHandler{
|
||||
Templates: templates,
|
||||
PageData: p,
|
||||
logger: s.logger,
|
||||
db: s.db,
|
||||
clientHandler := &ClientHandler{
|
||||
logger: s.logger,
|
||||
version: p.Version,
|
||||
baseURL: s.config.ClientsBaseURL,
|
||||
baseDirectory: s.config.ClientsBaseDirectory,
|
||||
}
|
||||
r.Path("/{id:[a-zA-Z0-9]+}/{password:[a-zA-Z0-9]+}.html").Handler(protectedWebNoteHandler).Methods("GET")
|
||||
r.Path("/clients/{os:[a-z]+}-{arch:[a-z0-9]+}/{clientName:[a-z]+}").Handler(clientHandler).Methods("GET")
|
||||
|
||||
webNoteHandler := &GetWebNoteHandler{
|
||||
Templates: templates,
|
||||
|
@ -163,8 +148,40 @@ func (s *Server) Start() error {
|
|||
}
|
||||
r.Path("/{id:[a-zA-Z0-9]+}.html").Handler(webNoteHandler).Methods("GET")
|
||||
|
||||
protectedWebNoteHandler := &GetProtectedWebNoteHandler{
|
||||
Templates: templates,
|
||||
PageData: p,
|
||||
logger: s.logger,
|
||||
db: s.db,
|
||||
maxUploadSize: s.config.MaxUploadSize,
|
||||
}
|
||||
r.Path("/{id:[a-zA-Z0-9]+}.html").Handler(protectedWebNoteHandler).Methods("POST")
|
||||
|
||||
rawWebNoteHandler := &GetRawWebNoteHandler{
|
||||
Templates: templates,
|
||||
PageData: p,
|
||||
logger: s.logger,
|
||||
db: s.db,
|
||||
}
|
||||
r.Path("/{id:[a-zA-Z0-9]+}").Handler(rawWebNoteHandler).Methods("GET")
|
||||
|
||||
protectedRawWebNoteHandler := &GetProtectedRawWebNoteHandler{
|
||||
Templates: templates,
|
||||
PageData: p,
|
||||
logger: s.logger,
|
||||
db: s.db,
|
||||
maxUploadSize: s.config.MaxUploadSize,
|
||||
}
|
||||
r.Path("/{id:[a-zA-Z0-9]+}").Handler(protectedRawWebNoteHandler).Methods("POST")
|
||||
|
||||
if s.config.AceDirectory != "" {
|
||||
r.PathPrefix("/static/ace-builds/").Handler(http.StripPrefix("/static/ace-builds/", http.FileServer(http.Dir(s.config.AceDirectory))))
|
||||
}
|
||||
|
||||
if s.config.BootstrapDirectory != "" {
|
||||
r.PathPrefix("/static/bootstrap/").Handler(http.StripPrefix("/static/bootstrap/", http.FileServer(http.Dir(s.config.BootstrapDirectory))))
|
||||
r.HandleFunc("/static/bootstrap/css/bootstrap.min.css", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, s.config.BootstrapDirectory+"/css/bootstrap.min.css")
|
||||
})
|
||||
}
|
||||
|
||||
r.Path("/").Handler(&HomeHandler{Templates: templates, PageData: p}).Methods("GET")
|
||||
|
@ -172,10 +189,10 @@ func (s *Server) Start() error {
|
|||
addr := fmt.Sprintf("%s:%d", s.config.ListenAddress, s.config.ListenPort)
|
||||
|
||||
if s.config.HasTLS() {
|
||||
s.logger.Info(fmt.Sprintf("listening to %s:%d (https)", s.config.ListenAddress, s.config.ListenPort))
|
||||
s.logger.Info(fmt.Sprintf("listening to %s:%d", s.config.ListenAddress, s.config.ListenPort), slog.Any("scheme", "http"))
|
||||
return http.ListenAndServeTLS(addr, s.config.TLSCertFile, s.config.TLSKeyFile, r)
|
||||
} else {
|
||||
s.logger.Info(fmt.Sprintf("listening to %s:%d (http)", s.config.ListenAddress, s.config.ListenPort))
|
||||
s.logger.Info(fmt.Sprintf("listening to %s:%d", s.config.ListenAddress, s.config.ListenPort), slog.Any("scheme", "https"))
|
||||
return http.ListenAndServe(addr, r)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,18 +8,18 @@
|
|||
{{block "header" .}}{{end}}
|
||||
|
||||
<div class="container mb-4 text-center">
|
||||
{{if eq .Err nil}}
|
||||
<div class="alert alert-success" role="alert">
|
||||
<p>Note created successfully</p>
|
||||
<p>
|
||||
<a href="{{.URL}}.html">{{.URL}}.html</a>
|
||||
</p>
|
||||
</div>
|
||||
{{else}}
|
||||
{{if .Err}}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<p>Could not create note</p>
|
||||
<p><strong>{{.Err}}</strong></p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="alert alert-success" role="alert">
|
||||
<p>Note created successfully</p>
|
||||
<p>
|
||||
<a href="{{.URL}}">{{.URL}}</a>
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
|
|
8
src/server/templates/error.html
Normal file
8
src/server/templates/error.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
{{define "error"}}
|
||||
<div class="container mb-4 text-center">
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<p>Could not show note</p>
|
||||
<p><strong>{{.Err}}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
|
@ -4,6 +4,8 @@
|
|||
<title>{{.Title}}</title>
|
||||
<meta charset="utf-8">
|
||||
<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>
|
||||
{{end}}
|
|
@ -13,19 +13,31 @@
|
|||
</div>
|
||||
<div class="container text-center justify-content-center w-75 mb-4">
|
||||
<div class="row align-items-center">
|
||||
{{if .EnablePasswordProtection}}
|
||||
<div class="col-1">
|
||||
<label class="col-form-label col-form-label-sm" for="password">Password</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<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"
|
||||
name="password">
|
||||
<input type="password" class="form-control" id="password" name="password">
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .AllowClientEncryptionKey}}
|
||||
<div class="col-1">
|
||||
<input type="checkbox" class="form-check-input" for="no-password" id="no-password"
|
||||
value="no-password" name="no-password">
|
||||
<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="encryption-key">Encryption key</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<input type="password" pattern="^[a-zA-Z0-9]{16,256}$"
|
||||
title="Letters and numbers with length from 16 to 256" class="form-control" id="encryption-key"
|
||||
name="encryption-key">
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .AllowNoEncryption}}
|
||||
<div class="col-1">
|
||||
<input type="checkbox" class="form-check-input" for="no-encryption" id="no-encryption"
|
||||
value="no-encryption" name="no-encryption">
|
||||
<label class="col-form-label col-form-label-sm" for="no-encryption">No encryption</label>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="col-1">
|
||||
<input type="checkbox" class="form-check-input" for="delete-after-read" id="delete-after-read"
|
||||
value="delete-after-read" name="delete-after-read">
|
||||
|
@ -40,15 +52,17 @@
|
|||
<select class="form-select" aria-label="Expiration" id="expiration" name="expiration">
|
||||
<option disabled>Expiration</option>
|
||||
{{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}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<select class="form-select" aria-label="Language" id="language" name="language">
|
||||
<option selected="selected" value="" disabled>Language</option>
|
||||
{{range .Languages}}
|
||||
<option value="{{lower .}}">{{.}}</option>
|
||||
<option disabled>Language</option>
|
||||
{{range $language := .Languages}}
|
||||
<option {{ if eq $language $.Language }}selected="selected" {{end}}value="{{lower .}}">
|
||||
{{$language}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
@ -56,11 +70,15 @@
|
|||
</div>
|
||||
|
||||
<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 id="editor" name="editor" class="form-control"
|
||||
style="height: 300px; resize: vertical; overflow: auto;"></div>
|
||||
style="min-height: 300px; resize: vertical; overflow: auto;"></div>
|
||||
<input type="hidden" id="content" />
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="container mb-4">
|
||||
<div class="row text-center justify-content-center">
|
||||
|
@ -69,38 +87,40 @@
|
|||
</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>
|
||||
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 () {
|
||||
var editor = monaco.editor.create(document.getElementById('editor'), {
|
||||
theme: document.documentElement.getAttribute('data-bs-theme') == 'light' ? "vs" : "vs-dark",
|
||||
language: document.getElementById("language").value,
|
||||
});
|
||||
// Syntax highlighting
|
||||
document.getElementById("language").addEventListener("change", (e) => {
|
||||
if (e.target.value != "") {
|
||||
editor.getSession().setMode("ace/mode/" + e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Syntax highlighting
|
||||
document.getElementById("language").addEventListener("change", (e) => {
|
||||
if (e.target.value != "") {
|
||||
monaco.editor.setModelLanguage(editor.getModel(), e.target.value);
|
||||
}
|
||||
});
|
||||
// Dark mode
|
||||
document.getElementById("lightSwitch").addEventListener("click", () => {
|
||||
if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
|
||||
editor.setTheme("ace/theme/github_light_default")
|
||||
} else if (document.documentElement.getAttribute('data-bs-theme') == 'dark') {
|
||||
editor.setTheme("ace/theme/github_dark")
|
||||
}
|
||||
});
|
||||
|
||||
// Dark mode
|
||||
document.getElementById("lightSwitch").addEventListener("click", () => {
|
||||
if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
|
||||
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());
|
||||
});
|
||||
// Copy content on submit
|
||||
document.getElementById("form").addEventListener("formdata", (e) => {
|
||||
e.formData.append('content', editor.getValue());
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
</form>
|
||||
|
||||
{{block "footer" .}}{{end}}
|
||||
|
|
|
@ -1,64 +1,129 @@
|
|||
{{define "note"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
{{if ne .Err nil}}
|
||||
{{block "error" .}}{{end}}
|
||||
{{else}}
|
||||
<div class="container mb-4">
|
||||
<div class="d-flex flex-wrap py-2">
|
||||
<span class="fs-4 d-flex mb-3 mb-md-0 me-md-auto text-decoration-none">Note {{.Note.ID}}</span>
|
||||
<ul class="nav nav-pills align-items-center">
|
||||
<li class="nav-item">
|
||||
<button type="button" class="btn btn-link" id="copier">copier</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button type="button" class="btn btn-link" id="curl">curl</button>
|
||||
</li>
|
||||
<li class="nav-item px-2">
|
||||
<a href="" id="rawURL">raw</a>
|
||||
<script>document.getElementById("rawURL").href = window.location.href.replace(".html", "");</script>
|
||||
</li>
|
||||
<li class="nav-item px-2">
|
||||
{{.Note.Language}}
|
||||
</li>
|
||||
{{if eq .Note.DeleteAfterRead false}}
|
||||
<li class="nav-item px-2">
|
||||
expires in {{HumanDuration (TimeDiff .Note.ExpiresAt)}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
<script>
|
||||
var password = "{{ .Password }}";
|
||||
var encryptionKey = window.location.hash.substr(1);
|
||||
</script>
|
||||
<div id="copierContainer" style="display: none;" class="alert alert-info alert-dismissible" role="alert">
|
||||
<p>Access the note with <strong>copier</strong>:</p>
|
||||
<pre id="copierCommand" style="border: 1px solid;" class="p-2"></pre>
|
||||
<button type="button" class="btn-close" data-dismiss="alert" aria-label="Close" id="copierClose">
|
||||
<span aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<script>
|
||||
var copierCommand = "copier";
|
||||
var copierOpts = "";
|
||||
if (password != "") {
|
||||
copierOpts += " -password '" + password + "'";
|
||||
}
|
||||
copierCommand += copierOpts + " " + window.location.origin + "/{{ .Note.ID }}";
|
||||
|
||||
{{block "head" .}}{{end}}
|
||||
|
||||
<body>
|
||||
{{block "header" .}}{{end}}
|
||||
|
||||
{{if ne .Err nil}}
|
||||
<div class="container mb-4 text-center">
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<p>Could not show note</p>
|
||||
<p><strong>{{.Err}}</strong></p>
|
||||
</div>
|
||||
if (encryptionKey != "") {
|
||||
copierCommand += "#" + encryptionKey;
|
||||
}
|
||||
document.getElementById("copierCommand").innerText = copierCommand;
|
||||
document.getElementById("copier").addEventListener("click", () => {
|
||||
document.getElementById("copierContainer").style.display = "";
|
||||
});
|
||||
document.getElementById("copierClose").addEventListener("click", () => {
|
||||
document.getElementById("copierContainer").style.display = "none";
|
||||
});
|
||||
</script>
|
||||
<div id="curlContainer" style="display: none;" class="alert alert-info alert-dismissible" role="alert">
|
||||
<p>Access the note with <strong>curl</strong>:</p>
|
||||
<pre id="curlCommand" style="border: 1px solid;" class="p-2"></pre>
|
||||
<button type="button" class="btn-close" data-dismiss="alert" aria-label="Close" id="curlClose">
|
||||
<span aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<script>
|
||||
var curlCommand = "curl";
|
||||
var curlData = {};
|
||||
if (encryptionKey != "") {
|
||||
curlData.encryption_key = encryptionKey;
|
||||
};
|
||||
if (password != "") {
|
||||
curlData.password = window.btoa(password);
|
||||
}
|
||||
var payload = JSON.stringify(curlData);
|
||||
if (payload != "{}") {
|
||||
curlCommand += " -XPOST -d '" + payload + "'";
|
||||
}
|
||||
curlCommand += " " + window.location.origin + "/api/note/{{ .Note.ID }}";
|
||||
document.getElementById("curlCommand").innerText = curlCommand;
|
||||
document.getElementById("curl").addEventListener("click", () => {
|
||||
document.getElementById("curlContainer").style.display = "";
|
||||
});
|
||||
document.getElementById("curlClose").addEventListener("click", () => {
|
||||
document.getElementById("curlContainer").style.display = "none";
|
||||
});
|
||||
</script>
|
||||
{{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="container mb-4">
|
||||
<div class="d-flex flex-wrap py-2">
|
||||
<span class="fs-4 d-flex mb-3 mb-md-0 me-md-auto text-decoration-none">Note {{.Note.ID}}</span>
|
||||
<ul class="nav nav-pills align-items-center">
|
||||
<li class="nav-item">
|
||||
<a href="" id="rawURL">raw</a>
|
||||
<script>document.getElementById("rawURL").href = window.location.href.replace(".html", "");</script>
|
||||
</li>
|
||||
<li class="nav-item px-2">
|
||||
{{.Note.Language}}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="row">
|
||||
<div id="editor" name="editor" class="form-control"
|
||||
style="min-height: 300px; resize: vertical; overflow: auto;">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div id="editor" name="editor" class="form-control"
|
||||
style="height: 300px; resize: vertical; overflow: auto;"></div>
|
||||
</div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs/loader.min.js"></script>
|
||||
<script>
|
||||
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs' } });
|
||||
|
||||
require(['vs/editor/editor.main'], function () {
|
||||
var editor = monaco.editor.create(document.getElementById('editor'), {
|
||||
theme: document.documentElement.getAttribute('data-bs-theme') == 'light' ? "vs" : "vs-dark",
|
||||
language: "{{.Note.Language}}",
|
||||
readOnly: true,
|
||||
value: "{{string .Note.Content}}"
|
||||
});
|
||||
|
||||
// Dark mode
|
||||
document.getElementById("lightSwitch").addEventListener("click", () => {
|
||||
if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
|
||||
monaco.editor.setTheme("vs")
|
||||
} else if (document.documentElement.getAttribute('data-bs-theme') == 'dark') {
|
||||
monaco.editor.setTheme("vs-dark")
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
<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>
|
||||
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 });
|
||||
|
||||
if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
|
||||
editor.setTheme("ace/theme/github_light_default");
|
||||
} else {
|
||||
editor.setTheme("ace/theme/github_dark");
|
||||
}
|
||||
|
||||
// Dark mode
|
||||
document.getElementById("lightSwitch").addEventListener("click", () => {
|
||||
if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
|
||||
editor.setTheme("ace/theme/github_light_default")
|
||||
} else if (document.documentElement.getAttribute('data-bs-theme') == 'dark') {
|
||||
editor.setTheme("ace/theme/github_dark")
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{block "footer" .}}{{end}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
21
src/server/templates/protectedNote.html
Normal file
21
src/server/templates/protectedNote.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
{{define "protectedNote"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
|
||||
{{block "head" .}}{{end}}
|
||||
|
||||
<body>
|
||||
{{block "header" .}}{{end}}
|
||||
|
||||
|
||||
{{if .Err}}
|
||||
{{block "error" .}}{{end}}
|
||||
{{else}}
|
||||
{{block "note" .}}{{end}}
|
||||
{{end}}
|
||||
|
||||
{{block "footer" .}}{{end}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{end}}
|
70
src/server/templates/unprotectedNote.html
Normal file
70
src/server/templates/unprotectedNote.html
Normal file
|
@ -0,0 +1,70 @@
|
|||
{{define "unprotectedNote"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
|
||||
{{block "head" .}}{{end}}
|
||||
|
||||
<body>
|
||||
{{block "header" .}}{{end}}
|
||||
|
||||
{{if .Err}}
|
||||
{{block "error" .}}{{end}}
|
||||
{{else if or (gt (len .Note.PasswordHash) 0) .Note.Encrypted}}
|
||||
<script>var protected = false;</script>
|
||||
<div class="container mb-4">
|
||||
<form id="form" method="post" enctype="multipart/form-data">
|
||||
{{if gt (len .Note.PasswordHash) 0}}
|
||||
<script>protected = true;</script>
|
||||
<div class="container mb-4 w-25">
|
||||
<div class="row text-center justify-content-center">
|
||||
<label class="col-form-label" for="password">Password</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container mb-4 w-25">
|
||||
<div class="row text-center justify-content-center">
|
||||
<input type="password" class="form-control" id="password" name="password">
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Note.Encrypted}}
|
||||
<div id="encryption-container">
|
||||
<div class="container mb-4 w-25">
|
||||
<div class="row text-center justify-content-center">
|
||||
<label class="col-form-label" for="password">Encryption key</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container mb-4 w-25">
|
||||
<div class="row text-center justify-content-center">
|
||||
<input type="password" pattern="^[a-zA-Z0-9]{16,256}$"
|
||||
title="Letters and numbers with length from 16 to 256" class="form-control"
|
||||
id="encryption-key" name="encryption-key">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var encryptionKey = window.location.hash.substr(1);
|
||||
if (encryptionKey != "") {
|
||||
document.getElementById("encryption-container").style.display = "none";
|
||||
document.getElementById("encryption-key").value = encryptionKey;
|
||||
if (!protected) {
|
||||
document.getElementById("form").submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
<div class="container mb-4 w-25">
|
||||
<div class="row text-center justify-content-center">
|
||||
<button type="btn-submit" id="btn-submit" class="btn btn-success">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}
|
||||
{{block "note" .}}{{end}}
|
||||
{{end}}
|
||||
|
||||
{{block "footer" .}}{{end}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{end}}
|
Loading…
Add table
Add a link
Reference in a new issue