1
0
Fork 0
forked from jriou/coller

Compare commits

..

No commits in common. "685914c32309e6ffb7c74377ff7f591f0b706abd" and "4b01308125e0a96a1180b7c68a9e43d2b866e929" have entirely different histories.

35 changed files with 687 additions and 1723 deletions

1
.gitignore vendored
View file

@ -3,4 +3,3 @@ releases
collerd.db
!docker/collerd.json
collerd.json
node_modules

View file

@ -4,53 +4,26 @@ GITCOMMIT := $(shell git log -1 --oneline | awk '{print $$1}')
OS := $(shell uname -s | tr [A-Z] [a-z])
ARCH := $(shell uname -m | tr [A-Z] [a-z])
LDFLAGS = -X main.AppVersion=${APPVERSION} -X main.GoVersion=${GOVERSION} -X main.GitCommit=${GITCOMMIT}
DOCKER_IMAGE = golang:1.24-trixie
.PHONY: clean clean_for_releases test
.PHONY: clean test
build:
cd src \
(cd src \
&& go build -ldflags "${LDFLAGS}" -o ../bin/collerd cmd/collerd/main.go \
&& go build -ldflags "${LDFLAGS}" -o ../bin/copier cmd/copier/main.go \
&& go build -ldflags "${LDFLAGS}" -o ../bin/coller cmd/coller/main.go
&& go build -ldflags "${LDFLAGS}" -o ../bin/coller cmd/coller/main.go \
)
build_linux_amd64:
cd src \
&& GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o ../bin/collerd-linux-amd64 cmd/collerd/main.go \
&& GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o ../bin/coller-linux-amd64 cmd/coller/main.go \
&& GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o ../bin/copier-linux-amd64 cmd/copier/main.go
archive:
(mkdir -p releases && cd bin && tar cvzpf ../releases/coller-${APPVERSION}-${OS}-${ARCH}.tar.gz * && cd ../releases && sha256sum *.tar.gz)
build_darwin_arm64:
cd src \
&& GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o ../bin/collerd-darwin-arm64 cmd/collerd/main.go \
&& GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o ../bin/coller-darwin-arm64 cmd/coller/main.go \
&& GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o ../bin/copier-darwin-arm64 cmd/copier/main.go
create_release:
mkdir -p releases/${APPVERSION} \
&& cp -p bin/collerd-linux-amd64 releases/${APPVERSION}/collerd-linux-amd64 \
&& cp -p bin/coller-linux-amd64 releases/${APPVERSION}/coller-linux-amd64 \
&& cp -p bin/copier-linux-amd64 releases/${APPVERSION}/copier-linux-amd64 \
&& cp -p bin/collerd-darwin-arm64 releases/${APPVERSION}/collerd-darwin-arm64 \
&& cp -p bin/coller-darwin-arm64 releases/${APPVERSION}/coller-darwin-arm64 \
&& cp -p bin/copier-darwin-arm64 releases/${APPVERSION}/copier-darwin-arm64
checksum:
cd releases/${APPVERSION} \
&& sha256sum collerd-* coller-* copier-* > checksums.txt
releases: build_linux_amd64 build_darwin_arm64 create_release checksum
releases_with_docker:
docker run -it -v $(shell pwd):/mnt -w /mnt -e "UID=$(shell id -u)" -e "GID=$(shell id -g)" ${DOCKER_IMAGE} ./docker/build.sh
dependencies:
npm install
release: build archive
test:
cd src \
(cd src \
&& go test internal/*.go \
&& go test server/*.go
&& go test server/*.go \
)
clean:
rm -rf bin releases

View file

@ -1 +1 @@
1.3.0
1.0.0

View file

@ -1,20 +0,0 @@
#!/bin/bash
# Script to create releases with Docker
# Used by `make releases_with_docker`
set -e
if [ -z "UID" ] ; then
echo "UID not defined"
exit 1
fi
if [ -z "GID" ] ; then
echo "GID not defined"
exit 1
fi
apt-get update
apt-get install -y libx11-dev
make releases
chown ${UID}:${GID} -R bin releases

46
package-lock.json generated
View file

@ -1,46 +0,0 @@
{
"name": "coller",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"ace-builds": "^1.43.3",
"bootstrap": "^5.3.8"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"peer": true,
"funding": {
"type": "opencollective",
"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",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
}
}
}

View file

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

View file

@ -22,17 +22,17 @@ Create from file:
coller -file filename.txt
```
Provide encryption key:
Provide password for encryption:
```
coller -ask-encryption-key
coller -encryption-key ENCRYPTION_KEY
coller -ask-password
coller -password PASSWORD
```
Create a note in cleartext:
Create public note:
```
coller -no-encryption
coller -no-password
```
Return the copier command to use client-side decryption instead of the URL:

View file

@ -13,10 +13,9 @@ 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 (
@ -32,12 +31,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 int64 `json:"id"`
ID string `json:"id"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
type Config struct {
@ -61,14 +60,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")
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")
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")
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")
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")
@ -77,7 +75,6 @@ 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()
@ -103,13 +100,7 @@ func handleMain() int {
if *quiet {
level = slog.LevelError
}
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}))
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
if *url == "" {
if _, err = os.Stat(*configFile); errors.Is(err, os.ErrNotExist) || *reconfigure {
@ -148,22 +139,22 @@ func handleMain() int {
content = clipboard.Read(clipboard.FmtText)
}
if *askEncryptionKey {
fmt.Print("Encryption key: ")
if *askPassword {
fmt.Print("Password: ")
p, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return internal.ReturnError(logger, "could not read encryption key", err)
return internal.ReturnError(logger, "could not read password", err)
}
*encryptionKey = string(p)
*password = string(p)
fmt.Print("\n")
}
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))
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))
}
*encryptionKey = internal.GenerateChars(*encryptionKeyLength)
*password = internal.GenerateChars(*passwordLength)
}
if len(content) == 0 {
@ -180,17 +171,14 @@ func handleMain() int {
if *language != "" {
p.Language = *language
}
if *password != "" {
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)
if *password != "" {
logger.Debug("validating password")
if err = internal.ValidatePassword(*password); err != nil {
return internal.ReturnError(logger, "invalid password", nil)
}
logger.Debug("encrypting content")
content, err = internal.Encrypt(content, *encryptionKey)
content, err = internal.Encrypt(content, *password)
if err != nil {
return internal.ReturnError(logger, "could not encrypt note", err)
}
@ -198,12 +186,8 @@ func handleMain() int {
}
logger.Debug("encoding content")
encodedContent := internal.Encode(content)
p.Content = encodedContent
logger.Debug("encoding password")
encodedPassword := internal.Encode([]byte(*password))
p.Password = encodedPassword
encoded := internal.Encode(content)
p.Content = encoded
payload, err := json.Marshal(p)
if err != nil {
@ -251,28 +235,27 @@ func handleMain() int {
}
if r.StatusCode != http.StatusOK {
return internal.ReturnError(logger, jsonBody.Message, nil)
return internal.ReturnError(logger, jsonBody.Message, fmt.Errorf("%s", jsonBody.Error))
}
logger.Debug("finding note location")
var location string
noteURL := *url + "/" + fmt.Sprintf("%d", jsonBody.ID)
if *copier {
location = "copier"
noteURL := *url + "/" + jsonBody.ID
if *password != "" {
location += " -password '" + *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)
}
location += " " + noteURL
if *encryptionKey != "" {
location += "#" + *encryptionKey
}
} else {
location = noteURL
if *html {
location += ".html"
}
if *encryptionKey != "" {
location += "#" + *encryptionKey
location = fmt.Sprintf("%s.html", noteURL)
} else {
location = fmt.Sprintf("%s", noteURL)
}
}

View file

@ -18,12 +18,8 @@ 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")
* **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)
* **id_length** (int): Number of characters for note identifiers (default 5)
* **password_length** (int): Number of characters for generated passwords (default 16)
* **expiration_interval** (int): Number of seconds to wait between two expiration runs
* **listen_address** (string): Address to listen for the web server (default "0.0.0.0")
* **listen_port** (int): Port to listen for the web server (default 8080)
@ -35,17 +31,11 @@ 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/ajaxorg/ace/tree/master/src/mode)
* **languages** ([]string): List of supported [languages](https://github.com/microsoft/monaco-editor/tree/main/src/basic-languages)
* **language** (string): Default language (default "text")
* **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.
* **enable_upload_file_button** (bool): Display the upload file button in the UI (default true)
The configuration file is optional.
The configuration file is not required but the service might not be exposed to the public.
## API
@ -59,79 +49,31 @@ Create a note.
Body (JSON):
* **content** (string): base64 encoded content (required)
* **encryption_key** (string): use server-side encryption with this encryption key
* **password** (string): use server-side encryption with this password
* **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** (int): ID of the note
* **id** (string): ID of the note
### GET /api/note/\<id\>/\<encryptionKey\>
### GET /\<id\>/\<password\>
> [!WARNING]
> Potential encryption key leak
> Potential password leak
Return content of a note encrypted by the given encryption key.
Return content of a note encrypted by the given password.
### 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\>
### GET /\<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): message of the error
## Dependencies
The web interface depends on:
- [Ace](https://ace.c9.io/)
- [Bootstrap](https://getbootstrap.com/)
By default, those dependencies are fetched from **remote CDN** services by the client.
If you would like to download and serve them locally:
```
npm install
```
or via `make`:
```
make dependencies
```
Then configure the local directories:
```json
{
"ace_directory": "./node_modules/ace-builds",
"bootstrap_directory": "./node_modules/bootstrap/dist"
}
```
* **message** (string): context of the error
* **error** (string): error message

View file

@ -5,10 +5,9 @@ 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 (
@ -27,7 +26,6 @@ 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()
@ -47,13 +45,7 @@ func handleMain() int {
if *quiet {
level = slog.LevelError
}
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}))
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
if *configFileName != "" {
err = internal.ReadConfig(*configFileName, config)
@ -77,7 +69,8 @@ func handleMain() int {
return internal.ReturnError(logger, "could not create server", err)
}
srv.SetEncryptionKeyLength(config.EncryptionKeyLength)
srv.SetIDLength(config.IDLength)
srv.SetPasswordLength(config.PasswordLength)
if config.EnableMetrics {
reg := prometheus.NewRegistry()

View file

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

View file

@ -1,20 +1,16 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"syscall"
"golang.org/x/term"
"git.riou.xyz/jriou/coller/internal"
"golang.org/x/term"
)
var (
@ -24,15 +20,6 @@ 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
@ -41,13 +28,11 @@ 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")
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")
password := flag.String("password", os.Getenv("COLLER_PASSWORD"), "Password to decrypt the note")
askPassword := flag.Bool("ask-password", false, "Read password 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()
@ -61,7 +46,7 @@ func handleMain() int {
return internal.RC_ERROR
}
rawURL := flag.Args()[0]
url := flag.Args()[0]
var level slog.Level
if *debug {
@ -73,21 +58,15 @@ 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 *askEncryptionKey {
fmt.Print("Encryption key: ")
if *askPassword {
fmt.Print("Password: ")
p, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return internal.ReturnError(logger, "could not read encryption key", err)
return internal.ReturnError(logger, "could not read password", err)
}
*encryptionKey = string(p)
*password = string(p)
fmt.Print("\n")
}
@ -101,46 +80,16 @@ func handleMain() int {
fmt.Print("\n")
}
logger.Debug("parsing url", slog.Any("url", rawURL))
u, err := url.Parse(rawURL)
if err != nil {
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))
req, err := http.NewRequest("GET", url, nil)
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("executing http request", slog.Any("method", req.Method), slog.Any("url", rawURL))
logger.Debug("parsing url", slog.Any("url", url))
r, err := http.DefaultClient.Do(req)
if err != nil {
return internal.ReturnError(logger, "could not retreive note", err)
@ -152,21 +101,12 @@ func handleMain() int {
return internal.ReturnError(logger, "could not read response", err)
}
if r.StatusCode != http.StatusOK {
jsonBody := &NoteResponse{}
err = json.Unmarshal(body, jsonBody)
if err != nil {
return internal.ReturnError(logger, "could not decode response", err)
}
return internal.ReturnError(logger, jsonBody.Message, nil)
}
var content []byte
if *encryptionKey != "" {
if *password != "" {
logger.Debug("decrypting note")
content, err = internal.Decrypt(body, *encryptionKey)
content, err = internal.Decrypt(body, *password)
if err != nil {
return internal.ReturnError(logger, "could not decrypt note", err)
return internal.ReturnError(logger, "could not decrypt paste", err)
}
} else {
content = body

View file

@ -1,15 +1,15 @@
module git.riou.xyz/jriou/coller
go 1.24.0
go 1.24
toolchain go1.24.7
toolchain go1.24.6
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.42.0
golang.org/x/term v0.35.0
golang.org/x/crypto v0.41.0
golang.org/x/term v0.34.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.30.0
@ -17,7 +17,6 @@ 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
@ -33,8 +32,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.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // 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
google.golang.org/protobuf v1.36.6 // indirect
)

View file

@ -1,7 +1,5 @@
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=
@ -52,8 +50,6 @@ 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=
@ -62,20 +58,12 @@ 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=

View file

@ -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 an encryption key using argon
func NewCipher(encryptionKey string, salt []byte) (cipher.AEAD, error) {
key := argon2.IDKey([]byte(encryptionKey), salt, KeyTime, KeyMemory, KeyThreads, KeySize)
// 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)
return chacha20poly1305.NewX(key)
}
// Encrypt to encrypt a plaintext with an encryption key
// Encrypt to encrypt a plaintext with a password
// Returns a byte slice with the generated salt, nonce and the ciphertext
func Encrypt(plaintext []byte, encryptionKey string) (result []byte, err error) {
func Encrypt(plaintext []byte, password 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(encryptionKey, salt)
aead, err := NewCipher(password, salt)
if err != nil {
return nil, err
}
@ -53,15 +53,15 @@ func Encrypt(plaintext []byte, encryptionKey string) (result []byte, err error)
return result, nil
}
// Decrypt to decrypt a ciphertext with a encryption key
// Decrypt to decrypt a ciphertext with a password
// Returns the plaintext
func Decrypt(ciphertext []byte, encryptionKey string) ([]byte, error) {
func Decrypt(ciphertext []byte, password string) ([]byte, error) {
if len(ciphertext) < SaltSize {
return nil, fmt.Errorf("ciphertext is too short: cannot read salt")
}
salt := ciphertext[:SaltSize]
aead, err := NewCipher(encryptionKey, salt)
aead, err := NewCipher(password, salt)
if err != nil {
return nil, err
}

View file

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

View file

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

View file

@ -9,7 +9,6 @@ import (
"path/filepath"
"regexp"
"strings"
"time"
)
func ReadConfig(file string, config interface{}) error {
@ -58,13 +57,13 @@ func GenerateChars(n int) string {
return string(b)
}
// Encryption key must be URL compatible and strong enough
// Passwords must be URL compatible and strong enough
// Requiring only alphanumeric chars with a size between 16 and 256
var encryptionKeyRegexp = regexp.MustCompile("^[a-zA-Z0-9]{16,256}$")
var passwordRegexp = regexp.MustCompile("^[a-zA-Z0-9]{16,256}$")
func ValidateEncryptionKey(p string) error {
if !encryptionKeyRegexp.MatchString(p) {
return fmt.Errorf("encryption key doesn't match '%s'", encryptionKeyRegexp)
func ValidatePassword(p string) error {
if !passwordRegexp.MatchString(p) {
return fmt.Errorf("password doesn't match '%s'", passwordRegexp)
}
return nil
}
@ -103,15 +102,6 @@ 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))
@ -127,12 +117,3 @@ func ToLowerStringSlice(src []string) (dst []string) {
}
return
}
func InSlice(s []string, elem string) bool {
for _, v := range s {
if v == elem {
return true
}
}
return false
}

View file

@ -85,25 +85,3 @@ func TestToLowerStringsSlice(t *testing.T) {
t.Logf("got '%s', want '%s'", got, expected)
}
}
func TestInSlice(t *testing.T) {
tests := []struct {
elem string
s []string
expected bool
}{
{"linux", []string{"linux", "darwin"}, true},
{"windows", []string{"linux", "darwin"}, false},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("TestInSlice#%s", tc.elem), func(t *testing.T) {
got := InSlice(tc.s, tc.elem)
if got != tc.expected {
t.Errorf("got '%t', want '%t'", got, tc.expected)
} else {
t.Logf("got '%t', want '%t'", got, tc.expected)
}
})
}
}

View file

@ -10,12 +10,8 @@ type Config struct {
Title string `json:"title"`
DatabaseType string `json:"database_type"`
DatabaseDsn string `json:"database_dsn"`
NodeID int64 `json:"node_id"`
EncryptionKeyLength int `json:"encryption_key_length"`
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"`
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"`
@ -29,13 +25,7 @@ type Config struct {
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"`
EnableUploadFileButton bool `json:"enable_upload_file_button"`
}
func NewConfig() *Config {
@ -43,8 +33,8 @@ func NewConfig() *Config {
Title: "Coller",
DatabaseType: "sqlite",
DatabaseDsn: "collerd.db",
NodeID: 1,
EncryptionKeyLength: 16,
IDLength: 5,
PasswordLength: 16,
ExpirationInterval: 60, // 1 minute
ListenAddress: "0.0.0.0",
ListenPort: 8080,
@ -63,26 +53,25 @@ func NewConfig() *Config {
PrometheusNotesMetric: "collerd_notes",
ObservationInterval: 60,
Languages: []string{
"css",
"dockerfile",
"golang",
"html",
"javascript",
"json",
"markdown",
"perl",
"python",
"ruby",
"rust",
"sh",
"sql",
"text",
"yaml",
"Text",
"CSS",
"Dockerfile",
"Go",
"HCL",
"HTML",
"Javascript",
"JSON",
"Markdown",
"Perl",
"Python",
"Ruby",
"Rust",
"Shell",
"SQL",
"YAML",
},
Language: "text",
EnableUploadFileButton: true,
EnablePasswordProtection: true,
ClientsBaseURL: "https://git.riou.xyz/jriou/coller/releases/download",
}
}
@ -96,16 +85,12 @@ func (c *Config) Check() error {
}
}
if c.NodeID < 0 || c.NodeID > 1023 {
return fmt.Errorf("node id must be between 0 and 1023")
if c.IDLength <= 0 {
return fmt.Errorf("identifiers length must be greater than zero")
}
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)
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)
}
return nil
}
func (c *Config) HasTLS() bool {
return c.TLSCertFile != "" && c.TLSKeyFile != ""
}

View file

@ -7,14 +7,11 @@ import (
"strings"
"time"
"github.com/bwmarrin/snowflake"
"golang.org/x/crypto/bcrypt"
"git.riou.xyz/jriou/coller/internal"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"git.riou.xyz/jriou/coller/internal"
)
type Database struct {
@ -25,7 +22,6 @@ type Database struct {
expiration int
languages []string
language string
node *snowflake.Node
}
var gconfig = &gorm.Config{
@ -52,11 +48,6 @@ 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,
@ -65,7 +56,6 @@ 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 {
@ -112,7 +102,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 != 0 {
if note.ID != "" {
if note.DeleteAfterRead {
if err := d.Delete(note.ID); err != nil {
return nil, err
@ -123,7 +113,7 @@ func (d *Database) Get(id string) (*Note, error) {
return nil, nil
}
func (d *Database) Create(content []byte, password []byte, encryptionKey string, encrypted bool, expiration int, deleteAfterRead bool, language string) (note *Note, err error) {
func (d *Database) Create(content []byte, password string, encrypted bool, expiration int, deleteAfterRead bool, language string) (note *Note, err error) {
if expiration == 0 {
expiration = d.expiration
}
@ -142,44 +132,32 @@ func (d *Database) Create(content []byte, password []byte, encryptionKey string,
}
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 encryptionKey != "" {
if err = internal.ValidateEncryptionKey(encryptionKey); err != nil {
if password != "" {
if err = internal.ValidatePassword(password); err != nil {
return nil, err
}
note.Content, err = internal.Encrypt(note.Content, encryptionKey)
note.Content, err = internal.Encrypt(note.Content, password)
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 int64) error {
func (d *Database) Delete(id string) error {
trx := d.db.Where("id = ?", id).Delete(&Note{})
defer trx.Commit()
if trx.Error != nil {

View file

@ -1,26 +0,0 @@
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")
)

View file

@ -1,268 +0,0 @@
package server
import (
"encoding/json"
"fmt"
"log/slog"
"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
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"`
Language string `json:"language"`
}
type CreateNoteResponse struct {
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 {
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 {
APIError(w, logger, ErrCouldNotDecodeContent, err)
return
}
password, err := internal.Decode(body.Password)
if err != nil {
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
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(CreateNoteResponse{ID: note.ID})
}
type GetNoteHandler struct {
logger *slog.Logger
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")
id := mux.Vars(r)["id"]
note, err := h.db.Get(id)
logger := h.logger.With("handler", h.Name(), "note_id", id)
if err != nil {
APIError(w, logger, ErrCouldNotFindNote, err)
} else if note == nil {
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")
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, string(note.Content))
}
}
type GetProtectedNoteHandler struct {
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) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
vars := mux.Vars(r)
id := vars["id"]
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 {
APIError(w, logger, ErrCouldNotFindNote, err)
return
} else if note == nil {
w.WriteHeader(http.StatusNotFound)
return
}
if body.EncryptionKey != "" && note.Encrypted {
note.Content, err = internal.Decrypt(note.Content, body.EncryptionKey)
if err != nil {
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
}
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, string(note.Content))
}
type ClientHandler struct {
logger *slog.Logger
version string
baseURL string
baseDirectory string
}
func (h *ClientHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.logger.Debug("rendering client redirection")
vars := mux.Vars(r)
os := vars["os"]
arch := vars["arch"]
clientName := vars["clientName"]
if !internal.InSlice(supportedOSes, os) || !internal.InSlice(supportedArches, arch) || !internal.InSlice(supportedClients, clientName) {
w.WriteHeader(http.StatusNotFound)
return
}
// 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
}
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)
}
}

View file

@ -1,459 +0,0 @@
package server
import (
"bytes"
"errors"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"strconv"
"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
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 {
Templates *template.Template
PageData PageData
}
func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.Templates.ExecuteTemplate(w, "index", h.PageData)
}
type CreateNoteWithFormHandler struct {
Templates *template.Template
PageData PageData
logger *slog.Logger
db *Database
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
logger := h.logger.With("handler", h.Name())
logger.Debug("parsing multipart form")
err := r.ParseMultipartForm(h.maxUploadSize)
if err != nil {
h.WebError(w, logger, ErrCouldNotParseForm, err)
return
}
logger.Debug("parsing content")
content := []byte(r.FormValue("content"))
logger.Debug("parsing file")
file, handler, err := r.FormFile("file")
if err != nil && !errors.Is(err, http.ErrMissingFile) {
h.WebError(w, logger, ErrCouldNotParseFile, err)
return
}
if !errors.Is(err, http.ErrMissingFile) {
defer file.Close()
logger.Debug("checking file size")
if handler.Size > h.maxUploadSize {
h.WebError(w, logger, ErrFileTooLarge, err)
return
}
logger.Debug("checking file content type")
if !strings.HasPrefix(handler.Header.Get("Content-Type"), "text/") {
h.WebError(w, logger, ErrTextFileExpected, err)
return
}
logger.Debug("reading uploaded file")
var fileContent bytes.Buffer
n, err := io.Copy(&fileContent, file)
if err != nil {
h.WebError(w, logger, ErrCouldNotReadFile, err)
return
}
logger.Debug("file uploaded", slog.Any("bytes", n))
if n != 0 {
content = fileContent.Bytes()
}
}
logger.Debug("checking content")
if content == nil || len(content) == 0 {
h.WebError(w, logger, ErrEmptyNote, nil)
return
}
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 !h.PageData.AllowNoEncryption && noEncryption != "" {
h.WebError(w, logger, ErrEncryptionRequired, nil)
}
if !h.PageData.AllowClientEncryptionKey && encryptionKey != "" {
h.WebError(w, logger, ErrClientEncryptionKeyNotAllowed, nil)
}
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, err = strconv.Atoi(expiration)
if err != nil {
h.WebError(w, logger, ErrInvalidExpiration, err)
return
}
}
logger.Debug("saving note to the database")
note, err := h.db.Create(content, []byte(password), encryptionKey, encryptionKey != "", expirationInt, deleteAfterRead != "", language)
if err != nil {
h.WebError(w, logger, ErrCouldNotCreateNote, err)
return
}
logger.Debug("building note url")
var scheme = "http://"
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/%d.html", scheme, r.Host, note.ID)
if encryptionKey != "" {
h.PageData.URL += "#" + encryptionKey
}
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 {
Templates *template.Template
PageData PageData
logger *slog.Logger
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
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.WebError(w, logger, ErrCouldNotFindNote, err)
return
}
if note == nil {
h.WebError(w, logger, ErrNoteDoesNotExist, nil)
return
}
h.PageData.Note = note
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
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")
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
}
note.Content, err = internal.Decrypt(note.Content, encryptionKey)
if err != nil {
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
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) {
h.logger.Debug("rendering clients web page")
h.Templates.ExecuteTemplate(w, "clients", h.PageData)
}

View file

@ -1,23 +1,47 @@
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 int64 `json:"id" gorm:"primaryKey"`
ID string `json:"id" gorm:"primaryKey"`
Content []byte `json:"content" gorm:"not null"`
Encrypted bool `json:"encrypted"`
PasswordHash []byte `json:"password_hash"`
ExpiresAt time.Time `json:"expires_at" gorm:"index"`
DeleteAfterRead bool `json:"delete_after_read"`
Language string `json:"language"`
}
// Compress content before saving to the database
// Generate ID and 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(&note)
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
}

View file

@ -1,25 +1,24 @@
package server
import (
"bytes"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"git.riou.xyz/jriou/coller/internal"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
"git.riou.xyz/jriou/coller/internal"
)
var (
encryptionKeyLength = internal.MIN_ENCRYPTION_KEY_LENGTH
supportedOSes = []string{"linux", "darwin"}
supportedArches = []string{"amd64", "arm64"}
supportedClients = []string{"coller", "copier"}
)
var passwordLength = internal.MIN_PASSWORD_LENGTH
type Server struct {
logger *slog.Logger
@ -40,15 +39,371 @@ func NewServer(logger *slog.Logger, db *Database, config *Config, version string
}, nil
}
func (s *Server) SetEncryptionKeyLength(length int) {
encryptionKeyLength = length
func (s *Server) SetIDLength(length int) {
idLength = length
}
func (s *Server) SetPasswordLength(length int) {
passwordLength = length
}
func (s *Server) SetMetrics(metrics *Metrics) {
s.metrics = metrics
}
//go:embed templates/*.html
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())
}
func HealthHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "OK")
}
type CreateNoteHandler struct {
logger *slog.Logger
db *Database
maxUploadSize int64
}
type CreateNotePayload struct {
Content string `json:"content"`
Password string `json:"password"`
Encrypted bool `json:"encrypted"`
Expiration int `json:"expiration"`
DeleteAfterRead bool `json:"delete_after_read"`
Language string `json:"language"`
}
type CreateNoteResponse struct {
ID string `json:"id"`
}
func (h *CreateNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
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)
return
}
content, err := internal.Decode(body.Content)
if err != nil {
WriteError(w, "could not decode content", err)
return
}
note, err := h.db.Create(content, body.Password, body.Encrypted, body.Expiration, body.DeleteAfterRead, body.Language)
if err != nil {
WriteError(w, "could not create note", err)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(CreateNoteResponse{ID: note.ID})
}
type GetNoteHandler struct {
logger *slog.Logger
db *Database
}
func (h *GetNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
id := mux.Vars(r)["id"]
note, err := h.db.Get(id)
if err != nil {
WriteError(w, "could not get note", err)
} else if note == nil {
w.WriteHeader(http.StatusNotFound)
} else {
if note.Encrypted {
w.Header().Set("Content-Type", "application/octet-stream")
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, string(note.Content))
}
}
type GetProtectedNoteHandler struct {
logger *slog.Logger
db *Database
}
func (h *GetProtectedNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
vars := mux.Vars(r)
id := vars["id"]
password := vars["password"]
note, err := h.db.Get(id)
if err != nil {
WriteError(w, "could not get note", err)
return
} else if note == nil {
w.WriteHeader(http.StatusNotFound)
return
}
if password != "" && note.Encrypted {
note.Content, err = internal.Decrypt(note.Content, password)
if err != nil {
WriteError(w, "could not decrypt note", err)
return
}
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, string(note.Content))
}
type PageData struct {
Title string
Version string
Expirations []int
Expiration int
Languages []string
Err error
URL string
Note *Note
EnableUploadFileButton bool
}
type HomeHandler struct {
Templates *template.Template
PageData PageData
}
func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.Templates.ExecuteTemplate(w, "index", h.PageData)
}
type CreateNoteWithFormHandler struct {
Templates *template.Template
PageData PageData
logger *slog.Logger
db *Database
maxUploadSize int64
}
func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.PageData.Err = nil
templateName := "create"
h.logger.Debug("parsing multipart form")
err := r.ParseMultipartForm(h.maxUploadSize)
if err != nil {
h.PageData.Err = err
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
return
}
h.logger.Debug("parsing content")
content := []byte(r.FormValue("content"))
h.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)
return
}
if !errors.Is(err, http.ErrMissingFile) {
defer file.Close()
h.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)
return
}
h.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)
return
}
h.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)
return
}
h.logger.Debug("file uploaded", slog.Any("bytes", n))
if n != 0 {
content = fileContent.Bytes()
}
}
h.logger.Debug("checking content")
if content == nil || len(content) == 0 {
h.PageData.Err = fmt.Errorf("empty note")
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
return
}
h.logger.Debug("checking inputs")
noPassword := r.FormValue("no-password")
password := r.FormValue("password")
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)
}
h.logger.Debug("computing expiration")
var expirationInt int
if expiration == "Expiration" {
expirationInt = 0
} else {
expirationInt, _ = strconv.Atoi(expiration)
}
h.logger.Debug("saving note to the database")
note, err := h.db.Create(content, password, password != "", expirationInt, deleteAfterRead != "", language)
if err != nil {
h.PageData.Err = err
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
return
}
h.logger.Debug("building note url")
var scheme = "http://"
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.logger.Debug("rendering page")
h.Templates.ExecuteTemplate(w, "create", h.PageData)
}
type GetWebNoteHandler struct {
Templates *template.Template
PageData PageData
logger *slog.Logger
db *Database
}
func (h *GetWebNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.PageData.Err = nil
templateName := "note"
vars := mux.Vars(r)
id := vars["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)
return
}
if note == nil {
h.PageData.Err = fmt.Errorf("note doesn't exist or has been deleted")
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
return
}
if note.Encrypted {
h.PageData.Err = fmt.Errorf("note is encrypted")
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
return
}
h.PageData.Note = note
h.logger.Debug("rendering note web page")
h.Templates.ExecuteTemplate(w, "note", h.PageData)
}
type GetProtectedWebNoteHandler struct {
Templates *template.Template
PageData PageData
logger *slog.Logger
db *Database
}
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"]
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)
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 password != "" && note.Encrypted {
note.Content, err = internal.Decrypt(note.Content, password)
if err != nil {
h.PageData.Err = fmt.Errorf("could not decrypt note: %v", err)
h.Templates.ExecuteTemplate(w, templateName, h.PageData)
return
}
}
h.PageData.Note = note
h.logger.Debug("rendering protected note web page")
h.Templates.ExecuteTemplate(w, "note", h.PageData)
}
//go:embed templates/*
var templatesFS embed.FS
func (s *Server) Start() error {
@ -63,32 +418,13 @@ func (s *Server) Start() error {
}
// API
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")
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")
// Web pages
funcs := template.FuncMap{
"HumanDuration": internal.HumanDuration,
"TimeDiff": internal.TimeDiff,
"lower": strings.ToLower,
"string": func(b []byte) string { return string(b) },
}
@ -97,25 +433,17 @@ func (s *Server) Start() error {
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,
@ -125,20 +453,13 @@ func (s *Server) Start() error {
}
r.Path("/create").Handler(createNoteWithFormHandler).Methods("POST")
clientsHandler := &ClientsHandler{
protectedWebNoteHandler := &GetProtectedWebNoteHandler{
Templates: templates,
PageData: p,
logger: s.logger,
db: s.db,
}
r.Path("/clients.html").Handler(clientsHandler).Methods("GET")
clientHandler := &ClientHandler{
logger: s.logger,
version: p.Version,
baseURL: s.config.ClientsBaseURL,
baseDirectory: s.config.ClientsBaseDirectory,
}
r.Path("/clients/{os:[a-z]+}-{arch:[a-z0-9]+}/{clientName:[a-z]+}").Handler(clientHandler).Methods("GET")
r.Path("/{id:[a-zA-Z0-9]+}/{password:[a-zA-Z0-9]+}.html").Handler(protectedWebNoteHandler).Methods("GET")
webNoteHandler := &GetWebNoteHandler{
Templates: templates,
@ -148,51 +469,9 @@ 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.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")
addr := fmt.Sprintf("%s:%d", s.config.ListenAddress, s.config.ListenPort)
if s.config.HasTLS() {
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", s.config.ListenAddress, s.config.ListenPort), slog.Any("scheme", "https"))
s.logger.Info(fmt.Sprintf("listening to %s:%d", s.config.ListenAddress, s.config.ListenPort))
return http.ListenAndServe(addr, r)
}
}

View file

@ -1,62 +0,0 @@
{{define "clients"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
{{block "head" .}}{{end}}
<body>
{{block "header" .}}{{end}}
<div class="container mb-4">
<p class="fs-4">Command-line clients</p>
</div>
<div class="container mb-4">
<table class="table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Docs</th>
<th scope="col">OS</th>
<th scope="col">Arch</th>
<th scope="col">Download</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" rowspan="2">coller</th>
<td rowspan="2"><a
href="https://git.riou.xyz/jriou/coller/src/{{if .Version}}tag/{{.Version}}{{else}}branch/main{{end}}/src/cmd/coller/README.md">📄</a>
</td>
<td>Linux</td>
<td>x86-64</td>
<td><a href="/clients/linux-amd64/coller">💾</a></td>
</tr>
<tr>
<td>macOS</td>
<td>ARM64</td>
<td><a href="/clients/darwin-arm64/coller">💾</a></td>
</tr>
<tr>
<th scope="row" rowspan="2">copier</th>
<td rowspan="2"><a
href="https://git.riou.xyz/jriou/coller/src/{{if .Version}}tag/{{.Version}}{{else}}branch/main{{end}}/src/cmd/copier/README.md">📄</a>
</td>
<td>Linux</td>
<td>x86-64</td>
<td><a href="/clients/linux-amd64/copier">💾</a></td>
</tr>
<tr>
<td>macOS</td>
<td>ARM64</td>
<td><a href="/clients/darwin-arm64/copier">💾</a></td>
</tr>
</tbody>
</table>
</div>
{{block "footer" .}}{{end}}
</body>
</html>
{{end}}

View file

@ -8,18 +8,18 @@
{{block "header" .}}{{end}}
<div class="container mb-4 text-center">
{{if .Err}}
<div class="alert alert-danger" role="alert">
<p>Could not create note</p>
<p><strong>{{.Err}}</strong></p>
</div>
{{else}}
{{if eq .Err nil}}
<div class="alert alert-success" role="alert">
<p>Note created successfully</p>
<p>
<a href="{{.URL}}">{{.URL}}</a>
<a href="{{.URL}}.html">{{.URL}}.html</a>
</p>
</div>
{{else}}
<div class="alert alert-danger" role="alert">
<p>Could not create note</p>
<p><strong>{{.Err}}</strong></p>
</div>
{{end}}
</div>

View file

@ -1,8 +0,0 @@
{{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}}

View file

@ -4,8 +4,6 @@
<title>{{.Title}}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
{{end}}

View file

@ -5,11 +5,6 @@
<a class="d-flex mb-3 mb-md-0 me-md-auto text-dark text-decoration-none" id="titleHeader" href="/">
<span class="fs-3">{{.Title}}</span>
</a>
<ul class="nav nav-pills align-items-center">
<li class="nav-item px-2">
<a href="/clients.html">Command-line clients</a>
</li>
</ul>
<ul class="nav nav-pills align-items-center">
<li class="nav-item">
<div class="form-check form-switch"

View file

@ -13,31 +13,19 @@
</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" class="form-control" id="password" name="password">
</div>
{{end}}
{{if .AllowClientEncryptionKey}}
<div class="col-1">
<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">
title="Letters and numbers with length from 16 to 256" class="form-control" id="password"
name="password">
</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>
<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>
</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">
@ -52,17 +40,15 @@
<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 disabled>Language</option>
{{range $language := .Languages}}
<option {{ if eq $language $.Language }}selected="selected" {{end}}value="{{lower .}}">
{{$language}}</option>
<option selected="selected" value="" disabled>Language</option>
{{range .Languages}}
<option value="{{lower .}}">{{.}}</option>
{{end}}
</select>
</div>
@ -70,15 +56,11 @@
</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="min-height: 300px; resize: vertical; overflow: auto;"></div>
style="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">
@ -87,40 +69,38 @@
</div>
</div>
</div>
{{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 src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs/loader.min.js"></script>
<script>
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.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: document.getElementById("language").value,
});
// Syntax highlighting
document.getElementById("language").addEventListener("change", (e) => {
if (e.target.value != "") {
editor.getSession().setMode("ace/mode/" + 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")
monaco.editor.setTheme("vs")
} else if (document.documentElement.getAttribute('data-bs-theme') == 'dark') {
editor.setTheme("ace/theme/github_dark")
monaco.editor.setTheme("vs-dark")
}
});
// Copy content on submit
document.getElementById("form").addEventListener("formdata", (e) => {
e.formData.append('content', editor.getValue());
e.formData.append('content', editor.getModel().getValue());
});
});
</script>
{{end}}
</form>
{{block "footer" .}}{{end}}

View file

@ -1,129 +1,64 @@
{{define "note"}}
{{if ne .Err nil}}
{{block "error" .}}{{end}}
{{else}}
<div class="container mb-4">
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
{{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>
</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">
<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 }}";
if (encryptionKey != "") {
copierCommand += "#" + encryptionKey;
}
document.getElementById("copierCommand").innerHTML = 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").innerHTML = 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="row">
<div id="editor" name="editor" class="form-control"
style="min-height: 300px; resize: vertical; overflow: auto;">
style="height: 300px; resize: vertical; overflow: auto;"></div>
</div>
</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 src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs/loader.min.js"></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 });
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs' } });
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: "{{.Note.Language}}",
readOnly: true,
value: "{{string .Note.Content}}"
});
// Dark mode
document.getElementById("lightSwitch").addEventListener("click", () => {
if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
editor.setTheme("ace/theme/github_light_default")
monaco.editor.setTheme("vs")
} else if (document.documentElement.getAttribute('data-bs-theme') == 'dark') {
editor.setTheme("ace/theme/github_dark")
monaco.editor.setTheme("vs-dark")
}
});
});
</script>
</div>
{{end}}
</div>
{{end}}
{{block "footer" .}}{{end}}
</body>
</html>
{{end}}

View file

@ -1,21 +0,0 @@
{{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}}

View file

@ -1,70 +0,0 @@
{{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}}