Initial commit
Signed-off-by: Julien Riou <julien@riou.xyz>
This commit is contained in:
commit
ef9aca1f3b
26 changed files with 1668 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
bin
|
||||
collerd.json
|
||||
collerd.db
|
9
LICENSE
Normal file
9
LICENSE
Normal 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
22
Makefile
Normal 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
1
VERSION
Normal file
|
@ -0,0 +1 @@
|
|||
0.0.1
|
254
src/cmd/coller/main.go
Normal file
254
src/cmd/coller/main.go
Normal 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
89
src/cmd/collerd/main.go
Normal 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
118
src/cmd/copier/main.go
Normal 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
31
src/go.mod
Normal 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
54
src/go.sum
Normal 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
13
src/internal/encoding.go
Normal 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)
|
||||
}
|
77
src/internal/encryption.go
Normal file
77
src/internal/encryption.go
Normal 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)
|
||||
}
|
44
src/internal/encryption_test.go
Normal file
44
src/internal/encryption_test.go
Normal 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
8
src/internal/internal.go
Normal 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
101
src/internal/utils.go
Normal 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)
|
||||
}
|
40
src/internal/utils_test.go
Normal file
40
src/internal/utils_test.go
Normal 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
31
src/server/compression.go
Normal 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
|
||||
}
|
27
src/server/compression_test.go
Normal file
27
src/server/compression_test.go
Normal 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
65
src/server/config.go
Normal 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
149
src/server/db.go
Normal 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(¬e)
|
||||
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 ¬e, 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
56
src/server/note.go
Normal 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(¬e)
|
||||
|
||||
// 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
351
src/server/server.go
Normal 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)
|
||||
}
|
30
src/server/templates/create.html
Normal file
30
src/server/templates/create.html
Normal 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}}
|
5
src/server/templates/footer.html
Normal file
5
src/server/templates/footer.html
Normal 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}}
|
7
src/server/templates/head.html
Normal file
7
src/server/templates/head.html
Normal 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}}
|
16
src/server/templates/header.html
Normal file
16
src/server/templates/header.html
Normal 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}}
|
67
src/server/templates/index.html
Normal file
67
src/server/templates/index.html
Normal 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}}
|
Loading…
Add table
Add a link
Reference in a new issue