diff --git a/.gitignore b/.gitignore index d2fe01d..cf8b1ff 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ releases collerd.db !docker/collerd.json collerd.json -node_modules diff --git a/Makefile b/Makefile index 8e56de7..d73d30f 100644 --- a/Makefile +++ b/Makefile @@ -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 \ - && 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 + (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 \ + ) -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 \ - && go test internal/*.go \ - && go test server/*.go + (cd src \ + && go test internal/*.go \ + && go test server/*.go \ + ) clean: rm -rf bin releases diff --git a/VERSION b/VERSION index f0bb29e..3eefcb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.0 +1.0.0 diff --git a/docker/build.sh b/docker/build.sh deleted file mode 100755 index 47c817d..0000000 --- a/docker/build.sh +++ /dev/null @@ -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 diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 65ad597..0000000 --- a/package-lock.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 5374149..0000000 --- a/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "dependencies": { - "ace-builds": "^1.43.3", - "bootstrap": "^5.3.8" - } -} diff --git a/src/cmd/coller/README.md b/src/cmd/coller/README.md index 1e3d178..1cf57a6 100644 --- a/src/cmd/coller/README.md +++ b/src/cmd/coller/README.md @@ -22,17 +22,17 @@ Create from file: coller -file filename.txt ``` -Provide 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: diff --git a/src/cmd/coller/main.go b/src/cmd/coller/main.go index e213740..dc75eab 100644 --- a/src/cmd/coller/main.go +++ b/src/cmd/coller/main.go @@ -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" - if *password != "" { - location += " -password '" + *password + "'" - } - location += " " + noteURL - if *encryptionKey != "" { - location += "#" + *encryptionKey + 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) + } } } else { - location = noteURL if *html { - location += ".html" - } - if *encryptionKey != "" { - location += "#" + *encryptionKey + location = fmt.Sprintf("%s.html", noteURL) + } else { + location = fmt.Sprintf("%s", noteURL) } } diff --git a/src/cmd/collerd/README.md b/src/cmd/collerd/README.md index a8936e4..d99a273 100644 --- a/src/cmd/collerd/README.md +++ b/src/cmd/collerd/README.md @@ -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/\/\ +### GET /\/\ > [!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/\/\ - -> [!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/\ +### GET /\ Return content of a note. If the note is encrypted, the encrypted value is returned (application/octet-stream). Otherwise, the text is returned (text/plain). -### POST /api/note/\ - -Return content of a protected note. - -If the note is encrypted, the encrypted value is returned (application/octet-stream). Otherwise, the text is returned (text/plain). - -Body (JSON): -* **password** (string): base64 encoded password used to protect the note (required) - ### Errors Errors return **500 Server Internal Error** with the **JSON** payload: -* **message** (string): 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" -} -``` \ No newline at end of file +* **message** (string): context of the error +* **error** (string): error message \ No newline at end of file diff --git a/src/cmd/collerd/main.go b/src/cmd/collerd/main.go index 2837dd4..35ca6a2 100644 --- a/src/cmd/collerd/main.go +++ b/src/cmd/collerd/main.go @@ -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() diff --git a/src/cmd/copier/README.md b/src/cmd/copier/README.md index ed268e0..81a7a88 100644 --- a/src/cmd/copier/README.md +++ b/src/cmd/copier/README.md @@ -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 ``` \ No newline at end of file diff --git a/src/cmd/copier/main.go b/src/cmd/copier/main.go index fc59e23..73c18d3 100644 --- a/src/cmd/copier/main.go +++ b/src/cmd/copier/main.go @@ -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)) - 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) - } + req, err := http.NewRequest("GET", url, 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 diff --git a/src/go.mod b/src/go.mod index 92236c0..35d8e39 100644 --- a/src/go.mod +++ b/src/go.mod @@ -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 ) diff --git a/src/go.sum b/src/go.sum index 455f4bf..2f772f9 100644 --- a/src/go.sum +++ b/src/go.sum @@ -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= diff --git a/src/internal/encryption.go b/src/internal/encryption.go index af8446b..be605bf 100644 --- a/src/internal/encryption.go +++ b/src/internal/encryption.go @@ -19,21 +19,21 @@ const ( // NewCipher creates a cipher using XChaCha20-Poly1305 // https://pkg.go.dev/golang.org/x/crypto/chacha20poly1305 -// A salt is required to derive the key from 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 } diff --git a/src/internal/encryption_test.go b/src/internal/encryption_test.go index 6ff44d4..14b5f6a 100644 --- a/src/internal/encryption_test.go +++ b/src/internal/encryption_test.go @@ -6,10 +6,10 @@ import ( func TestEncryptAndDecrypt(t *testing.T) { plaintext := "test" - 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 } } diff --git a/src/internal/internal.go b/src/internal/internal.go index 9141158..2780ae9 100644 --- a/src/internal/internal.go +++ b/src/internal/internal.go @@ -1,8 +1,8 @@ package internal const ( - RC_OK = 0 - RC_ERROR = 1 - MIN_ENCRYPTION_KEY_LENGTH = 16 - MAX_ENCRYPTION_KEY_LENGTH = 256 + RC_OK = 0 + RC_ERROR = 1 + MIN_PASSWORD_LENGTH = 16 + MAX_PASSWORD_LENGTH = 256 ) diff --git a/src/internal/utils.go b/src/internal/utils.go index 601f001..c98c2e3 100644 --- a/src/internal/utils.go +++ b/src/internal/utils.go @@ -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 -} diff --git a/src/internal/utils_test.go b/src/internal/utils_test.go index eef7c03..cc251f0 100644 --- a/src/internal/utils_test.go +++ b/src/internal/utils_test.go @@ -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) - } - }) - } -} diff --git a/src/server/config.go b/src/server/config.go index 9e41bd8..8ba51ca 100644 --- a/src/server/config.go +++ b/src/server/config.go @@ -7,47 +7,37 @@ import ( ) 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"` - 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"` + 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"` } func NewConfig() *Config { return &Config{ - Title: "Coller", - DatabaseType: "sqlite", - DatabaseDsn: "collerd.db", - NodeID: 1, - EncryptionKeyLength: 16, - ExpirationInterval: 60, // 1 minute - ListenAddress: "0.0.0.0", - ListenPort: 8080, + Title: "Coller", + DatabaseType: "sqlite", + DatabaseDsn: "collerd.db", + IDLength: 5, + PasswordLength: 16, + ExpirationInterval: 60, // 1 minute + ListenAddress: "0.0.0.0", + ListenPort: 8080, Expirations: []int{ 300, // 5 minutes 3600, // 1 hour @@ -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", + Language: "text", + EnableUploadFileButton: true, } } @@ -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 != "" -} diff --git a/src/server/db.go b/src/server/db.go index 9f10855..0186b72 100644 --- a/src/server/db.go +++ b/src/server/db.go @@ -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 { diff --git a/src/server/errors.go b/src/server/errors.go deleted file mode 100644 index f685728..0000000 --- a/src/server/errors.go +++ /dev/null @@ -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") -) diff --git a/src/server/handlers_api.go b/src/server/handlers_api.go deleted file mode 100644 index 56b2451..0000000 --- a/src/server/handlers_api.go +++ /dev/null @@ -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) - } -} diff --git a/src/server/handlers_web.go b/src/server/handlers_web.go deleted file mode 100644 index c1a38ac..0000000 --- a/src/server/handlers_web.go +++ /dev/null @@ -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) -} diff --git a/src/server/note.go b/src/server/note.go index 6f20e1a..bff6b73 100644 --- a/src/server/note.go +++ b/src/server/note.go @@ -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(¬e) + + if note.ID == "" { + n.ID = id + continue + } + } + if n.ID == "" { + return fmt.Errorf("could not find unique id before creating the note") + } + n.Content = Compress(n.Content) return nil } diff --git a/src/server/server.go b/src/server/server.go index 77950a1..703714d 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -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,59 +418,32 @@ 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) }, } p := PageData{ - 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, + Title: s.config.Title, + Expirations: s.config.Expirations, + Expiration: s.config.Expiration, + Languages: s.config.Languages, } 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")) - return http.ListenAndServe(addr, r) - } + s.logger.Info(fmt.Sprintf("listening to %s:%d", s.config.ListenAddress, s.config.ListenPort)) + return http.ListenAndServe(addr, r) } diff --git a/src/server/templates/clients.html b/src/server/templates/clients.html deleted file mode 100644 index 6e57267..0000000 --- a/src/server/templates/clients.html +++ /dev/null @@ -1,62 +0,0 @@ -{{define "clients"}} - - - -{{block "head" .}}{{end}} - - - {{block "header" .}}{{end}} -
-

Command-line clients

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameDocsOSArchDownload
coller📄 - Linuxx86-64💾
macOSARM64💾
copier📄 - Linuxx86-64💾
macOSARM64💾
-
- - {{block "footer" .}}{{end}} - - - -{{end}} \ No newline at end of file diff --git a/src/server/templates/create.html b/src/server/templates/create.html index 569ebbf..9970fa9 100644 --- a/src/server/templates/create.html +++ b/src/server/templates/create.html @@ -8,18 +8,18 @@ {{block "header" .}}{{end}}
- {{if .Err}} - - {{else}} + {{if eq .Err nil}} + {{else}} + {{end}}
diff --git a/src/server/templates/error.html b/src/server/templates/error.html deleted file mode 100644 index 6a48b44..0000000 --- a/src/server/templates/error.html +++ /dev/null @@ -1,8 +0,0 @@ -{{define "error"}} -
- -
-{{end}} \ No newline at end of file diff --git a/src/server/templates/head.html b/src/server/templates/head.html index ab2edfd..bd8edba 100644 --- a/src/server/templates/head.html +++ b/src/server/templates/head.html @@ -4,8 +4,6 @@ {{.Title}} - + {{end}} \ No newline at end of file diff --git a/src/server/templates/header.html b/src/server/templates/header.html index 091455d..def42eb 100644 --- a/src/server/templates/header.html +++ b/src/server/templates/header.html @@ -5,11 +5,6 @@ {{.Title}} - + +
+
+
+ + + // 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") + } + }); + }); + + {{end}} - -{{end}} + + {{block "footer" .}}{{end}} + + + {{end}} \ No newline at end of file diff --git a/src/server/templates/protectedNote.html b/src/server/templates/protectedNote.html deleted file mode 100644 index 8b413d4..0000000 --- a/src/server/templates/protectedNote.html +++ /dev/null @@ -1,21 +0,0 @@ -{{define "protectedNote"}} - - - -{{block "head" .}}{{end}} - - - {{block "header" .}}{{end}} - - - {{if .Err}} - {{block "error" .}}{{end}} - {{else}} - {{block "note" .}}{{end}} - {{end}} - - {{block "footer" .}}{{end}} - - - -{{end}} \ No newline at end of file diff --git a/src/server/templates/unprotectedNote.html b/src/server/templates/unprotectedNote.html deleted file mode 100644 index 4df1de4..0000000 --- a/src/server/templates/unprotectedNote.html +++ /dev/null @@ -1,70 +0,0 @@ -{{define "unprotectedNote"}} - - - -{{block "head" .}}{{end}} - - - {{block "header" .}}{{end}} - - {{if .Err}} - {{block "error" .}}{{end}} - {{else if or (gt (len .Note.PasswordHash) 0) .Note.Encrypted}} - -
-
- {{if gt (len .Note.PasswordHash) 0}} - -
-
- -
-
-
-
- -
-
- {{end}} - {{if .Note.Encrypted}} -
-
-
- -
-
-
-
- -
-
-
- - {{end}} -
-
- -
-
-
-
- {{else}} - {{block "note" .}}{{end}} - {{end}} - - {{block "footer" .}}{{end}} - - - -{{end}} \ No newline at end of file