Initial commit

Signed-off-by: Julien Riou <julien@riou.xyz>
This commit is contained in:
Julien Riou 2025-08-21 16:22:03 +02:00
commit ef9aca1f3b
Signed by: jriou
GPG key ID: 9A099EDA51316854
26 changed files with 1668 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
bin
collerd.json
collerd.db

9
LICENSE Normal file
View file

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2025 jriou
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

22
Makefile Normal file
View file

@ -0,0 +1,22 @@
APPVERSION := $(shell cat ./VERSION)
GOVERSION := $(shell go version | awk '{print $$3}')
GITCOMMIT := $(shell git log -1 --oneline | awk '{print $$1}')
LDFLAGS = -X main.AppVersion=${APPVERSION} -X main.GoVersion=${GOVERSION} -X main.GitCommit=${GITCOMMIT}
.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 \
)
test:
(cd src \
&& go test internal/*.go \
&& go test server/*.go \
)
clean:
rm -rf bin

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.0.1

254
src/cmd/coller/main.go Normal file
View file

@ -0,0 +1,254 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"syscall"
"git.riou.xyz/jriou/coller/internal"
"golang.design/x/clipboard"
"golang.org/x/term"
)
var (
AppName = "coller"
AppVersion string
GoVersion string
GitCommit string
)
type NotePayload struct {
Content string `json:"content"`
Encrypted bool `json:"encrypted"`
Expiration int `json:"expiration,omitempty"`
DeleteAfterRead bool `json:"delete_after_read,omitempty"`
}
type NoteResponse struct {
ID string `json:"id"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
type Config struct {
URL string `json:"url"`
}
func handleMain() int {
var err error
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Printf("could not find home directory: %v\n", err)
return internal.RC_ERROR
}
version := flag.Bool("version", false, "Print version and exit")
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")
configFile := flag.String("config", filepath.Join(homeDir, ".config", AppName+".json"), "Configuration file")
reconfigure := flag.Bool("reconfigure", false, "Re-create configuration file")
url := flag.String("url", "", "URL of the coller API")
password := flag.String("password", os.Getenv("COPIER_PASSWORD"), "Password to decrypt 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")
fileName := flag.String("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")
copier := flag.Bool("copier", false, "Print the copier command to decrypt the note")
flag.Parse()
if *version {
internal.ShowVersion(AppName, AppVersion, GitCommit, GoVersion)
return internal.RC_OK
}
var level slog.Level
if *debug {
level = slog.LevelDebug
}
if *verbose {
level = slog.LevelInfo
}
if *quiet {
level = slog.LevelError
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
if *url == "" {
if _, err = os.Stat(*configFile); errors.Is(err, os.ErrNotExist) || *reconfigure {
logger.Debug("creating config")
config := CreateConfig()
logger.Debug("writing configuration file")
err := WriteConfig(config, *configFile)
if err != nil {
return ReturnError(logger, "could not create configuration file", err)
}
}
logger.Debug("reading configuration file", slog.Any("config", *configFile))
var config Config
err = internal.ReadConfig(*configFile, &config)
if err != nil {
return ReturnError(logger, "could not read configuration file", err)
}
*url = config.URL
}
var content []byte
if *fileName != "" {
logger.Debug("reading from file", slog.Any("file", *fileName))
content, err = os.ReadFile(*fileName)
if err != nil {
return ReturnError(logger, "could not read from file", err)
}
} else {
err = clipboard.Init()
if err != nil {
return ReturnError(logger, "could not initialize clipboard library", err)
}
content = clipboard.Read(clipboard.FmtText)
}
if *askPassword {
fmt.Print("Password: ")
p, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return ReturnError(logger, "could not read password", err)
}
*password = string(p)
fmt.Print("\n")
}
if !*noPassword && *password == "" {
logger.Debug("generating random password")
if *passwordLength < internal.MIN_PASSWORD_LENGTH || *passwordLength > internal.MAX_PASSWORD_LENGTH {
return 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))
}
*password = internal.GenerateChars(*passwordLength)
}
if len(content) == 0 {
return ReturnError(logger, "could not create empty note", nil)
}
p := NotePayload{}
if *expiration != 0 {
p.Expiration = *expiration
}
if *deleteAfterRead {
p.DeleteAfterRead = *deleteAfterRead
}
if *password != "" {
logger.Debug("validating password")
if err = internal.ValidatePassword(*password); err != nil {
return ReturnError(logger, "invalid password", nil)
}
logger.Debug("encrypting content")
content, err = internal.Encrypt(content, *password)
if err != nil {
return ReturnError(logger, "could not encrypt note", err)
}
p.Encrypted = true
}
logger.Debug("encoding content")
encoded := internal.Encode(content)
p.Content = encoded
payload, err := json.Marshal(p)
if err != nil {
return ReturnError(logger, "could not serialize note to json", err)
}
apiRoute := *url + "/api/note"
logger.Debug("creating note", slog.Any("payload", payload), slog.Any("url", apiRoute))
r, err := http.Post(apiRoute, "application/json", bytes.NewReader(payload))
if err != nil {
return ReturnError(logger, "could not create note", err)
}
logger.Debug("reading response", slog.Any("response", r))
body, err := io.ReadAll(r.Body)
if err != nil {
return ReturnError(logger, "could not read response", err)
}
jsonBody := &NoteResponse{}
err = json.Unmarshal(body, jsonBody)
if err != nil {
return ReturnError(logger, "could not decode response", err)
}
if r.StatusCode != http.StatusOK {
return ReturnError(logger, jsonBody.Message, fmt.Errorf("%s", jsonBody.Error))
}
logger.Debug("finding note location")
var location string
noteURL := *url + "/" + jsonBody.ID
if *password != "" {
if *copier {
location = fmt.Sprintf("copier -password %s %s", *password, noteURL)
} else {
location = fmt.Sprintf("%s/%s", noteURL, *password)
}
} else {
location = fmt.Sprintf("%s", noteURL)
}
logger.Debug("displaying note location")
fmt.Printf("%s\n", location)
return internal.RC_OK
}
func main() {
os.Exit(handleMain())
}
func CreateConfig() Config {
var url string
fmt.Print("Instance URL: ")
fmt.Scan(&url)
return Config{URL: url}
}
func WriteConfig(config Config, fileName string) error {
content, err := json.Marshal(config)
if err != nil {
return err
}
err = os.WriteFile(fileName, content, 0644)
if err != nil {
return err
}
return nil
}
func ReturnError(logger *slog.Logger, message string, err error) int {
if err != nil {
logger.Error(message, slog.Any("error", err))
} else {
logger.Error(message)
}
return internal.RC_ERROR
}

89
src/cmd/collerd/main.go Normal file
View file

@ -0,0 +1,89 @@
package main
import (
"flag"
"log/slog"
"os"
"git.riou.xyz/jriou/coller/internal"
"git.riou.xyz/jriou/coller/server"
)
var (
AppName = "collerd"
AppVersion string
GoVersion string
GitCommit string
)
func handleMain() int {
var err error
config := server.NewConfig()
version := flag.Bool("version", false, "Print version and exit")
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")
configFileName := flag.String("config", "", "Configuration file name")
flag.Parse()
if *version {
internal.ShowVersion(AppName, AppVersion, GitCommit, GoVersion)
return internal.RC_OK
}
var level slog.Level
if *debug {
level = slog.LevelDebug
}
if *verbose {
level = slog.LevelInfo
}
if *quiet {
level = slog.LevelError
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
if *configFileName != "" {
err = internal.ReadConfig(*configFileName, config)
if err != nil {
slog.Error("cannot parse configuration file", slog.Any("error", err))
return internal.RC_ERROR
}
logger.Debug("configuration file parsed", slog.Any("file", *configFileName))
}
if err = config.Check(); err != nil {
logger.Error("invalid configuration", slog.Any("error", err))
return internal.RC_ERROR
}
db, err := server.NewDatabase(logger, config)
if err != nil {
slog.Error("could not connect to the database", slog.Any("error", err))
return internal.RC_ERROR
}
srv, err := server.NewServer(logger, db, config, AppVersion)
if err != nil {
logger.Error("could not create server", slog.Any("error", err))
return internal.RC_ERROR
}
srv.SetIDLength(config.IDLength)
srv.SetPasswordLength(config.PasswordLength)
err = srv.Start()
if err != nil {
logger.Error("could not start server", slog.Any("error", err))
return internal.RC_ERROR
}
return internal.RC_OK
}
func main() {
os.Exit(handleMain())
}

118
src/cmd/copier/main.go Normal file
View file

@ -0,0 +1,118 @@
package main
import (
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"syscall"
"git.riou.xyz/jriou/coller/internal"
"golang.org/x/term"
)
var (
AppName = "copier"
AppVersion string
GoVersion string
GitCommit string
)
func handleMain() int {
flag.Usage = usage
version := flag.Bool("version", false, "Print version and exit")
quiet := flag.Bool("quiet", false, "Log errors only")
verbose := flag.Bool("verbose", false, "Print more logs")
debug := flag.Bool("debug", false, "Print even more logs")
password := flag.String("password", os.Getenv("COPIER_PASSWORD"), "Password to decrypt the note")
askPassword := flag.Bool("w", false, "Read password from input")
fileName := flag.String("file", "", "Write content of the note to a file")
flag.Parse()
if flag.NArg() != 1 {
usage()
return internal.RC_ERROR
}
url := flag.Args()[0]
if *version {
internal.ShowVersion(AppName, AppVersion, GitCommit, GoVersion)
return internal.RC_OK
}
var level slog.Level
if *debug {
level = slog.LevelDebug
}
if *verbose {
level = slog.LevelInfo
}
if *quiet {
level = slog.LevelError
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
if *askPassword {
fmt.Print("Password: ")
p, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
logger.Error("could not read password", slog.Any("error", err))
return internal.RC_ERROR
}
*password = string(p)
}
logger.Debug("parsing url", slog.Any("url", url))
r, err := http.Get(url)
if err != nil {
logger.Error("could not retreive note", slog.Any("error", err))
return internal.RC_ERROR
}
logger.Debug("decoding body")
body, err := io.ReadAll(r.Body)
if err != nil {
logger.Error("could not read response", slog.Any("error", err))
return internal.RC_ERROR
}
var content []byte
if *password != "" {
logger.Debug("decrypting note")
content, err = internal.Decrypt(body, *password)
if err != nil {
logger.Error("could not decrypt paste", slog.Any("error", err))
return internal.RC_ERROR
}
} else {
content = body
}
if *fileName != "" {
logger.Debug("writing output to file", slog.Any("file", *fileName))
err = os.WriteFile(*fileName, content, 0644)
if err != nil {
logger.Error("could not write output to file", slog.Any("error", err))
return internal.RC_ERROR
}
} else {
fmt.Printf("%s", content)
}
return internal.RC_OK
}
func usage() {
fmt.Printf("Usage: %s [OPTIONS] URL\n", os.Args[0])
flag.PrintDefaults()
}
func main() {
os.Exit(handleMain())
}

31
src/go.mod Normal file
View file

@ -0,0 +1,31 @@
module git.riou.xyz/jriou/coller
go 1.24
toolchain go1.24.6
require (
github.com/gorilla/mux v1.8.1
golang.design/x/clipboard v0.7.1
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
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect
golang.org/x/image v0.28.0 // indirect
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
)

54
src/go.sum Normal file
View file

@ -0,0 +1,54 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
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/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=
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8=
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/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=

13
src/internal/encoding.go Normal file
View file

@ -0,0 +1,13 @@
package internal
import (
"encoding/base64"
)
func Encode(data []byte) string {
return base64.StdEncoding.EncodeToString(data)
}
func Decode(data string) ([]byte, error) {
return base64.StdEncoding.DecodeString(data)
}

View file

@ -0,0 +1,77 @@
package internal
import (
"crypto/cipher"
"crypto/rand"
"fmt"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/chacha20poly1305"
)
const (
SaltSize = 32
KeySize = uint32(32) // KeySize is 32 bytes (256 bits).
KeyTime = uint32(5)
KeyMemory = uint32(1024 * 64) // KeyMemory in KiB. here, 64 MiB.
KeyThreads = uint8(4)
)
// NewCipher creates a cipher using XChaCha20-Poly1305
// https://pkg.go.dev/golang.org/x/crypto/chacha20poly1305
// A salt is required to derive the key from a password using argon
func NewCipher(password string, salt []byte) (cipher.AEAD, error) {
key := argon2.IDKey([]byte(password), salt, KeyTime, KeyMemory, KeyThreads, KeySize)
return chacha20poly1305.NewX(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, 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(password, salt)
if err != nil {
return nil, err
}
result = append(result, salt...)
nonce := make([]byte, aead.NonceSize())
if m, err := rand.Read(nonce); err != nil || m != aead.NonceSize() {
return nil, err
}
result = append(result, nonce...)
ciphertext := aead.Seal(nil, nonce, plaintext, nil)
result = append(result, ciphertext...)
return result, nil
}
// Decrypt to decrypt a ciphertext with a password
// Returns the plaintext
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(password, salt)
if err != nil {
return nil, err
}
if len(ciphertext) < SaltSize+aead.NonceSize() {
return nil, fmt.Errorf("ciphertext is too short: cannot read nonce")
}
nonce := ciphertext[SaltSize : SaltSize+aead.NonceSize()]
ciphertext = ciphertext[SaltSize+aead.NonceSize():]
return aead.Open(nil, nonce, ciphertext, nil)
}

View file

@ -0,0 +1,44 @@
package internal
import (
"testing"
)
func TestEncryptAndDecrypt(t *testing.T) {
plaintext := "test"
password := "test"
wrongPassword := password + "wrong"
ciphertext, err := Encrypt([]byte(plaintext), password)
if err != nil {
t.Errorf("unexpected error when encrypting: %v", err)
return
}
if plaintext == string(ciphertext) {
t.Errorf("plaintext and ciphertext are equal")
return
}
cleartext, err := Decrypt(ciphertext, password)
if err != nil {
t.Errorf("unexpected error when decrypting: %v", err)
return
}
if string(cleartext) != string(plaintext) {
t.Errorf("got '%s', expected '%s'", cleartext, plaintext)
return
}
if password == wrongPassword {
t.Errorf("passwords must be different")
return
}
_, err = Decrypt(ciphertext, wrongPassword)
if err == nil {
t.Errorf("expected error when decrypting with a wrong password, got none")
return
}
}

8
src/internal/internal.go Normal file
View file

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

101
src/internal/utils.go Normal file
View file

@ -0,0 +1,101 @@
package internal
import (
"encoding/json"
"fmt"
"math/rand"
"os"
"path/filepath"
"regexp"
)
func ReadConfig(file string, config interface{}) error {
file, err := filepath.Abs(file)
if err != nil {
return err
}
jsonFile, err := os.ReadFile(file)
if err != nil {
return err
}
err = json.Unmarshal(jsonFile, &config)
if err != nil {
return err
}
return nil
}
func Version(appName, appVersion, gitCommit, goVersion string) string {
version := appName
if appVersion != "" {
version += " " + appVersion
}
if gitCommit != "" {
version += "-" + gitCommit
}
if goVersion != "" {
version += " (compiled with Go " + goVersion + ")"
}
return version
}
func ShowVersion(appName, appVersion, gitCommit, goVersion string) {
fmt.Print(Version(appName, appVersion, gitCommit, goVersion) + "\n")
}
const randomChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func GenerateChars(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = randomChars[rand.Intn(len(randomChars))]
}
return string(b)
}
// Passwords must be URL compatible and strong enough
// Requiring only alphanumeric chars with a size between 16 and 256
var passwordRegexp = regexp.MustCompile("^[a-zA-Z0-9]{16,256}$")
func ValidatePassword(p string) error {
if !passwordRegexp.MatchString(p) {
return fmt.Errorf("password doesn't match '%s'", passwordRegexp)
}
return nil
}
// HumanDuration converts number of seconds to a human friendly format
// Ex: 3600 -> "1 hour"
// Yes it uses rough approximations ¯\_(ツ)_/¯
func HumanDuration(i int) string {
var w string
if i < 0 {
return ""
} else if i >= 0 && i < 60 {
w = "second"
} else if i >= 60 && i < 60*60 {
i = i / 60
w = "minute"
} else if i >= 60*60 && i < 60*60*24 {
w = "hour"
i = i / (60 * 60)
} else if i >= 60*60*24 && i < 60*60*24*7 {
w = "day"
i = i / (60 * 60 * 24)
} else if i >= 60*60*24*7 && i < 60*60*24*31 {
w = "week"
i = i / (60 * 60 * 24 * 7)
} else if i >= 60*60*24*31 && i < 60*60*24*365 {
w = "month"
i = i / (60 * 60 * 24 * 31)
} else {
w = "year"
i = i / (60 * 60 * 24 * 365)
}
if i > 1 {
w = w + "s"
}
return fmt.Sprintf("%d %s", i, w)
}

View file

@ -0,0 +1,40 @@
package internal
import (
"fmt"
"testing"
)
func TestHumanDuration(t *testing.T) {
tests := []struct {
i int
expected string
}{
{-1, ""},
{1, "1 second"},
{3, "3 seconds"},
{60, "1 minute"},
{120, "2 minutes"},
{3600, "1 hour"},
{7200, "2 hours"},
{86400, "1 day"},
{172800, "2 days"},
{604800, "1 week"},
{1209600, "2 weeks"},
{2678400, "1 month"},
{5356800, "2 months"},
{31536000, "1 year"},
{63072000, "2 years"},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("TestHumanDuration#%d", tc.i), func(t *testing.T) {
got := HumanDuration(tc.i)
if got != tc.expected {
t.Errorf("got '%s', want '%s'", got, tc.expected)
} else {
t.Logf("got '%s', want '%s'", got, tc.expected)
}
})
}
}

31
src/server/compression.go Normal file
View file

@ -0,0 +1,31 @@
package server
import (
"bytes"
"compress/gzip"
)
func Compress(data []byte) []byte {
var b bytes.Buffer
w := gzip.NewWriter(&b)
w.Write(data)
w.Close()
return b.Bytes()
}
func Decompress(data []byte) ([]byte, error) {
b := bytes.NewBuffer(data)
r, err := gzip.NewReader(b)
if err != nil {
return nil, err
}
var w bytes.Buffer
_, err = w.ReadFrom(r)
if err != nil {
return nil, err
}
return w.Bytes(), nil
}

View file

@ -0,0 +1,27 @@
package server
import (
"testing"
)
func TestCompressAndDecompress(t *testing.T) {
text := "test"
compressed := Compress([]byte(text))
if text == string(compressed) {
t.Errorf("text and compressed text are equal")
return
}
decompressed, err := Decompress(compressed)
if err != nil {
t.Errorf("unexpected error when decompressing: %v", err)
return
}
if string(text) != string(decompressed) {
t.Errorf("got '%s', expected '%s'", decompressed, text)
return
}
}

65
src/server/config.go Normal file
View file

@ -0,0 +1,65 @@
package server
import (
"fmt"
"git.riou.xyz/jriou/coller/internal"
)
type Config struct {
Title string `json:"title"`
DatabaseType string `json:"database_type"`
DatabaseDsn string `json:"database_dsn"`
IDLength int `json:"id_length"`
PasswordLength int `json:"password_length"`
ExpirationInterval int `json:"expiration_interval"`
ListenAddress string `json:"listen_address"`
ListenPort int `json:"listen_port"`
Expirations []int `json:"expirations"`
Expiration int `json:"expiration"`
MaxUploadSize int64 `json:"max_upload_size"`
ShowVersion bool `json:"show_version"`
}
func NewConfig() *Config {
return &Config{
Title: "Coller",
DatabaseType: "sqlite",
DatabaseDsn: "collerd.db",
IDLength: 5,
PasswordLength: 16,
ExpirationInterval: 60, // 1 minute
ListenAddress: "127.0.0.1",
ListenPort: 8080,
Expirations: []int{
300, // 5 minutes
3600, // 1 hour
86400, // 1 day
604800, // 7 days
18144000, // 30 days
},
Expiration: 604800, // 7 days
MaxUploadSize: 10485760, // 10MiB (encoded)
ShowVersion: false,
}
}
func (c *Config) Check() error {
if len(c.Expirations) == 0 {
return fmt.Errorf("expirations are required")
}
for _, e := range c.Expirations {
if e <= 0 {
return fmt.Errorf("invalid expiration %d: must be greater than zero", e)
}
}
if c.IDLength <= 0 {
return fmt.Errorf("identifiers length must be greater than zero")
}
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
}

149
src/server/db.go Normal file
View file

@ -0,0 +1,149 @@
package server
import (
"fmt"
"log/slog"
"slices"
"strings"
"time"
"git.riou.xyz/jriou/coller/internal"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type Database struct {
logger *slog.Logger
db *gorm.DB
expirationInterval int
expirations []int
expiration int
}
var gconfig = &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
}
func NewDatabase(logger *slog.Logger, config *Config) (d *Database, err error) {
l := logger.With("module", "db")
logger.Debug("connecting to the database")
var db *gorm.DB
switch config.DatabaseType {
case "postgres":
db, err = gorm.Open(postgres.New(postgres.Config{DSN: config.DatabaseDsn}), gconfig)
default:
db, err = gorm.Open(sqlite.Open(config.DatabaseDsn), gconfig)
}
if err != nil {
return nil, err
}
logger.Debug("connected to the database")
d = &Database{
logger: l,
db: db,
expirationInterval: config.ExpirationInterval,
expirations: config.Expirations,
expiration: config.Expiration,
}
if err = d.UpdateSchema(); err != nil {
return nil, err
}
go d.StartExpireThread()
return d, nil
}
func (d *Database) UpdateSchema() error {
d.logger.Debug("updating database schema")
if err := d.db.AutoMigrate(&Note{}); err != nil {
return err
}
d.logger.Debug("database schema updated")
return nil
}
func (d *Database) StartExpireThread() {
for {
d.logger.Debug("deleting expired notes")
trx := d.db.Where("expires_at <= ?", time.Now()).Delete(&Note{})
if trx.Error != nil {
d.logger.Error("could not delete note", slog.Any("error", trx.Error))
}
d.logger.Debug("expired notes deleted")
wording := "second"
if d.expirationInterval > 1 {
wording += "s"
}
d.logger.Debug(fmt.Sprintf("waiting for %d %s before next expiration", d.expirationInterval, wording))
time.Sleep(time.Duration(d.expirationInterval) * time.Second)
}
}
func (d *Database) Get(id string) (*Note, error) {
var note Note
trx := d.db.Where("id = ?", id).Find(&note)
if trx.Error != nil {
d.logger.Warn("could not find note", slog.Any("error", trx.Error))
return nil, trx.Error
}
if note.ID != "" {
if note.DeleteAfterRead {
if err := d.Delete(note.ID); err != nil {
return nil, err
}
}
return &note, nil
}
return nil, nil
}
func (d *Database) Create(content []byte, password string, encrypted bool, expiration int, deleteAfterRead bool) (note *Note, err error) {
if expiration == 0 {
expiration = d.expiration
}
if !slices.Contains(d.expirations, expiration) {
validExpirations := strings.Trim(strings.Join(strings.Fields(fmt.Sprint(d.expirations)), ", "), "[]")
return nil, fmt.Errorf("invalid expiration: must be one of %s", validExpirations)
}
note = &Note{
Content: content,
ExpiresAt: time.Now().Add(time.Duration(expiration) * time.Second),
Encrypted: encrypted,
DeleteAfterRead: deleteAfterRead,
}
if password != "" {
if err = internal.ValidatePassword(password); err != nil {
return nil, err
}
note.Content, err = internal.Encrypt(note.Content, password)
if err != nil {
return nil, err
}
note.Encrypted = true
}
trx := d.db.Create(note)
if trx.Error != nil {
d.logger.Warn("could not create note", slog.Any("error", trx.Error))
return nil, trx.Error
}
return note, nil
}
func (d *Database) Delete(id string) error {
trx := d.db.Where("id = ?", id).Delete(&Note{})
if trx.Error != nil {
d.logger.Error("could not delete note", slog.Any("error", trx.Error))
return trx.Error
}
return nil
}

56
src/server/note.go Normal file
View file

@ -0,0 +1,56 @@
package server
import (
"fmt"
"time"
"git.riou.xyz/jriou/coller/internal"
"gorm.io/gorm"
)
const ID_MAX_RETRIES = 10
var idLength = 5
type Note struct {
ID string `json:"id" gorm:"primaryKey"`
Content []byte `json:"content" gorm:"not null"`
Encrypted bool `json:"encrypted"`
ExpiresAt time.Time `json:"expires_at" gorm:"index"`
DeleteAfterRead bool `json:"delete_after_read"`
}
// Generate ID and compress content before saving to the database
func (n *Note) BeforeCreate(tx *gorm.DB) (err error) {
for i := 0; i < ID_MAX_RETRIES; i++ {
if n.ID != "" {
continue
}
id := internal.GenerateChars(idLength)
var note *Note
tx.Where("id = ?", id).Scan(&note)
// TODO: time=2025-08-21T09:20:31.342+02:00 level=ERROR msg="could not create note" error="UNIQUE constraint failed: notes.id"
if note == nil {
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
}
// Decompress content from the database
func (n *Note) AfterFind(tx *gorm.DB) (err error) {
n.Content, err = Decompress(n.Content)
if err != nil {
return err
}
return nil
}

351
src/server/server.go Normal file
View file

@ -0,0 +1,351 @@
package server
import (
"bytes"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"strconv"
"git.riou.xyz/jriou/coller/internal"
"github.com/gorilla/mux"
)
var passwordLength = internal.MIN_PASSWORD_LENGTH
type Server struct {
logger *slog.Logger
db *Database
config *Config
version string
}
func NewServer(logger *slog.Logger, db *Database, config *Config, version string) (*Server, error) {
l := logger.With("module", "server")
return &Server{
logger: l,
db: db,
config: config,
version: version,
}, nil
}
func (s *Server) SetIDLength(length int) {
idLength = length
}
func (s *Server) SetPasswordLength(length int) {
passwordLength = length
}
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 HeathHandler(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"`
}
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)
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")
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")
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
Err error
URL string
}
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 handler.Header.Get("Content-Type") != "text/plain" {
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 {
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")
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 != "")
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)
}
//go:embed templates/*
var templatesFS embed.FS
func (s *Server) Start() error {
r := mux.NewRouter().StrictSlash(true)
// API
r.HandleFunc("/health", HeathHandler)
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,
}
p := PageData{
Title: s.config.Title,
Expirations: s.config.Expirations,
}
if s.config.ShowVersion {
p.Version = s.version
}
templates, err := template.New("templates").Funcs(funcs).ParseFS(templatesFS, "templates/*.html")
if err != nil {
return err
}
createNoteWithFormHandler := &CreateNoteWithFormHandler{
Templates: templates,
PageData: p,
logger: s.logger,
db: s.db,
maxUploadSize: s.config.MaxUploadSize,
}
r.Path("/create").Handler(createNoteWithFormHandler).Methods("POST")
r.Path("/").Handler(&HomeHandler{Templates: templates, PageData: p}).Methods("GET")
addr := fmt.Sprintf("%s:%d", s.config.ListenAddress, s.config.ListenPort)
return http.ListenAndServe(addr, r)
}

View file

@ -0,0 +1,30 @@
{{define "create"}}
<!DOCTYPE html>
<html lang="en">
{{block "head" .}}{{end}}
<body>
{{block "header" .}}{{end}}
<div class="container mb-4 text-center">
{{if eq .Err nil}}
<div class="alert alert-success" role="alert">
<p>Note created successfully</p>
<p>
<a href="{{.URL}}">{{.URL}}</a>
</p>
</div>
{{else}}
<div class="alert alert-danger" role="alert">
<p>Could not create note</p>
<p><strong>{{.Err}}</strong></p>
</div>
</div>
{{end}}
{{block "footer" .}}{{end}}
</body>
</html>
{{end}}

View file

@ -0,0 +1,5 @@
{{define "footer"}}
<footer class="text-center">
<small>Powered by <a href="https://git.riou.xyz/jriou/coller">coller</a> {{if ne .Version ``}}({{.Version}}){{end}}</small>
</footer>
{{end}}

View file

@ -0,0 +1,7 @@
{{define "head"}}
<head>
<title>{{.Title}}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
</head>
{{end}}

View file

@ -0,0 +1,16 @@
{{define "header"}}
<div class="text-center justify-content-center align-items-center bg-light border-bottom mb-4">
<div class="container">
<header class="d-flex flex-wrap py-2">
<a class="d-flex mb-3 mb-md-0 me-md-auto text-dark text-decoration-none" href="/">
<span class="fs-3">{{.Title}}</span>
</a>
<ul class="nav nav-pills">
<li class="nav-item">
<a class="nav-link active" href="/" aria-content="page">New note</a>
</li>
</ul>
</header>
</div>
</div>
{{end}}

View file

@ -0,0 +1,67 @@
{{define "index"}}
<!DOCTYPE html>
<html lang="en">
{{block "head" .}}{{end}}
<body>
{{block "header" .}}{{end}}
<form action="/create" method="post" enctype="multipart/form-data">
<div class="container mb-4">
<p class="fs-4">New note</p>
</div>
<div class="container text-center justify-content-center w-75 mb-4">
<div class="row align-items-center">
<div class="col-1">
<label class="col-form-label col-form-label-sm" for="password">Password</label>
</div>
<div class="col">
<input type="password" pattern="^[a-zA-Z0-9]{16,256}$"
title="Letters and numbers with length from 16 to 256" class="form-control" id="password"
name="password">
</div>
<div class="col-1">
<input type="checkbox" class="form-check-input" for="no-password" id="no-password"
value="no-password" name="no-password">
<label class="col-form-label col-form-label-sm" for="no-password">No password</label>
</div>
<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">
<label class="col-form-label col-form-label-sm" for="delete-after-read">Delete after read</label>
</div>
<div class="col">
<input type="file" class="form-control" for="file" id="file" name="file" accept="text/plain" />
</div>
<div class="col">
<select class="form-select" aria-label="Expiration" id="expiration" name="expiration">
<option selected>Expiration</option>
{{range .Expirations}}
<option value="{{.}}">{{HumanDuration .}}</option>
{{end}}
</select>
</div>
</div>
</div>
<div class="container mb-4">
<div class="row">
<textarea class="form-control" id="content" name="content" cols="40" rows="12"></textarea>
</div>
</div>
<div class="container mb-4">
<div class="row text-center justify-content-center">
<div class="col-1">
<button type="submit" class="btn btn-success">Create</button>
</div>
</div>
</div>
</form>
{{block "footer" .}}{{end}}
</body>
</html>
{{end}}