feat: initial commit

This commit is contained in:
wpetit 2025-02-21 18:42:56 +01:00
commit ee4a65b345
81 changed files with 3441 additions and 0 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
/data.slite*
/bin
/tmp
/tools

20
.env.dist Normal file
View File

@ -0,0 +1,20 @@
# See internal/config for more configuration parameters
# Log level (here DEBUG)
CLEARCASE_LOGGER_LEVEL=-4
# Gitea auth provider (disabled if empty)
CLEARCASE_AUTH_PROVIDERS_GITEA_KEY=
CLEARCASE_AUTH_PROVIDERS_GITEA_SECRET=
CLEARCASE_AUTH_PROVIDERS_GITEA_SCOPES="user:email"
# HTTP session keys (list of 32 characters strings, should be modified in production)
CLEARCASE_HTTP_SESSION_KEYS=abcdefghijklmnopqrstuvwxyz000000
# Base URL, used in templates and link generation
CLEARCASE_HTTP_BASE_URL=http://localhost:3001
# LLM Provider
# Example with ollama - llama3.1:8b :
CLEARCASE_LLM_PROVIDER_BASE_URL="http://localhost:11434/api/"
CLEARCASE_LLM_PROVIDER_MODEL="llama3.1:8b"

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
/bin
/tmp
/tools
*.db
*.sqlite*
/.env

27
.goreleaser.yaml Normal file
View File

@ -0,0 +1,27 @@
version: 2
before:
hooks:
- go mod tidy
builds:
- targets: [go_first_class]
mod_timestamp: "{{ .CommitTimestamp }}"
dir: ./cmd/clearcase
flags:
- -trimpath
ldflags:
- -w -s -X 'forge.cadoles.com/wpetit/clearcase/internal/build.ShortVersion={{ .Version }}' -X 'forge.cadoles.com/wpetit/clearcase/internal/build.LongVersion={{ .Version }}'
checksum:
name_template: "checksums.txt"
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
release:
name_template: "v{{ .Version }}"

54
Makefile Normal file
View File

@ -0,0 +1,54 @@
SHELL := /bin/bash
GIT_SHORT_VERSION ?= $(shell git describe --tags --abbrev=8 --always)
GIT_LONG_VERSION ?= $(shell git describe --tags --abbrev=8 --dirty --always --long)
LDFLAGS ?= -w -s \
-X 'forge.cadoles.com/wpetit/clearcase/internal/build.ShortVersion=$(GIT_SHORT_VERSION)' \
-X 'forge.cadoles.com/wpetit/clearcase/internal/build.LongVersion=$(GIT_LONG_VERSION)'
GCFLAGS ?= -trimpath=$(PWD)
ASMFLAGS ?= -trimpath=$(PWD) \
CI_EVENT ?= push
watch: tools/modd/bin/modd bin/templ
tools/modd/bin/modd
run-with-env: .env
( set -o allexport && source .env && set +o allexport && $(value CMD))
build: generate
CGO_ENABLED=0 \
go build \
-ldflags "$(LDFLAGS)" \
-gcflags "$(GCFLAGS)" \
-asmflags "$(ASMFLAGS)" \
-o ./bin/clearcase ./cmd/clearcase
generate: tools/templ/bin/templ
tools/templ/bin/templ generate
bin/templ: tools/templ/bin/templ
mkdir -p bin
ln -fs $(PWD)/tools/templ/bin/templ bin/templ
tools/templ/bin/templ:
mkdir -p tools/templ/bin
GOBIN=$(PWD)/tools/templ/bin go install github.com/a-h/templ/cmd/templ@v0.3.819
tools/modd/bin/modd:
mkdir -p tools/modd/bin
GOBIN=$(PWD)/tools/modd/bin go install github.com/cortesi/modd/cmd/modd@latest
tools/act/bin/act:
mkdir -p tools/act/bin
cd tools/act && curl https://raw.githubusercontent.com/nektos/act/master/install.sh | bash -
ci: tools/act/bin/act
tools/act/bin/act $(CI_EVENT)
.env:
cp .env.dist .env
include misc/*/*.mk

16
README.md Normal file
View File

@ -0,0 +1,16 @@
# Rkvst
## Getting started
```bash
# Generate .env from template
make .env
# Update .env with your own values
vim .env
# Start application
make watch
```
Then open http://localhost:3000 in your browser.

49
cmd/clearcase/main.go Normal file
View File

@ -0,0 +1,49 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"forge.cadoles.com/wpetit/clearcase/internal/config"
"forge.cadoles.com/wpetit/clearcase/internal/setup"
"github.com/pkg/errors"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
conf, err := config.Parse()
if err != nil {
slog.ErrorContext(ctx, "could not parse config", slog.Any("error", errors.WithStack(err)))
os.Exit(1)
}
slog.SetLogLoggerLevel(slog.Level(conf.Logger.Level))
slog.DebugContext(ctx, "using configuration", slog.Any("config", conf))
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
go func() {
slog.InfoContext(ctx, "use ctrl+c to interrupt")
<-sig
cancel()
}()
server, err := setup.NewHTTPServerFromConfig(ctx, conf)
if err != nil {
slog.ErrorContext(ctx, "could not setup http server", slog.Any("error", errors.WithStack(err)))
os.Exit(1)
}
slog.InfoContext(ctx, "starting server", slog.Any("address", conf.HTTP.Address))
if err := server.Run(ctx); err != nil {
slog.Error("could not run server", slog.Any("error", errors.WithStack(err)))
os.Exit(1)
}
}

29
go.mod Normal file
View File

@ -0,0 +1,29 @@
module forge.cadoles.com/wpetit/clearcase
go 1.23.1
require (
github.com/a-h/templ v0.3.819
github.com/caarlos0/env/v11 v11.2.2
github.com/gabriel-vasile/mimetype v1.4.7
github.com/gorilla/sessions v1.1.1
github.com/markbates/goth v1.80.0
github.com/pkg/errors v0.9.1
github.com/samber/slog-http v1.4.4
)
require (
cloud.google.com/go/compute v1.20.1 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/mux v1.6.2 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/oauth2 v0.17.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.34.1 // indirect
)

80
go.sum Normal file
View File

@ -0,0 +1,80 @@
cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg=
cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
github.com/a-h/templ v0.3.819 h1:KDJ5jTFN15FyJnmSmo2gNirIqt7hfvBD2VXVDTySckM=
github.com/a-h/templ v0.3.819/go.mod h1:iDJKJktpttVKdWoTkRNNLcllRI+BlpopJc+8au3gOUo=
github.com/caarlos0/env/v11 v11.2.2 h1:95fApNrUyueipoZN/EhA8mMxiNxrBwDa+oAZrMWl3Kg=
github.com/caarlos0/env/v11 v11.2.2/go.mod h1:JBfcdeQiBoI3Zh1QRAWfe+tpiNTmDtcCj/hHHHMx0vc=
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/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.1 h1:YMDmfaK68mUixINzY/XjscuJ47uXFWSSHzFbBQM0PrE=
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/markbates/goth v1.80.0 h1:NnvatczZDzOs1hn9Ug+dVYf2Viwwkp/ZDX5K+GLjan8=
github.com/markbates/goth v1.80.0/go.mod h1:4/GYHo+W6NWisrMPZnq0Yr2Q70UntNLn7KXEFhrIdAY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/samber/slog-http v1.4.4 h1:NuENLy39Lk6b7wfj9cG9R5C/JLZR4t6pb9cwlyroybI=
github.com/samber/slog-http v1.4.4/go.mod h1:PAcQQrYFo5KM7Qbk50gNNwKEAMGCyfsw6GN5dI0iv9g=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,4 @@
package build
var ShortVersion = "unknown"
var LongVersion = "unknown"

30
internal/config/auth.go Normal file
View File

@ -0,0 +1,30 @@
package config
type Auth struct {
DefaultAdmin DefaultAdmin `envPrefix:"DEFAULT_ADMIN_"`
Providers AuthProviders `envPrefix:"PROVIDERS_"`
}
type DefaultAdmin struct {
Email string `env:"EMAIL,expand"`
Provider string `env:"PROVIDER,expand"`
}
type AuthProviders struct {
Google OAuth2Provider `envPrefix:"GOOGLE_"`
Github OAuth2Provider `envPrefix:"GITHUB_"`
OIDC OIDCProvider `envPrefix:"OIDC_"`
}
type OAuth2Provider struct {
Key string `env:"KEY,expand"`
Secret string `env:"SECRET,expand"`
Scopes []string `env:"SCOPES",expand"`
}
type OIDCProvider struct {
OAuth2Provider
DiscoveryURL string `env:"DISCOVERY_URL,expand"`
Icon string `env:"ICON,expand" envDefault:"fa-passport"`
Label string `env:"LABEL,expand" envDefault:"OpenID Connect"`
}

25
internal/config/config.go Normal file
View File

@ -0,0 +1,25 @@
package config
import (
"github.com/caarlos0/env/v11"
"github.com/pkg/errors"
)
type Config struct {
Logger Logger `envPrefix:"LOGGER_"`
Auth Auth `envPrefix:"AUTH_"`
HTTP HTTP `envPrefix:"HTTP_"`
Storage Storage `envPrefix:"STORAGE_"`
LLM LLM `envPrefix:"LLM_"`
}
func Parse() (*Config, error) {
conf, err := env.ParseAsWithOptions[Config](env.Options{
Prefix: "CLEARCASE_",
})
if err != nil {
return nil, errors.WithStack(err)
}
return &conf, nil
}

21
internal/config/http.go Normal file
View File

@ -0,0 +1,21 @@
package config
import "time"
type HTTP struct {
BaseURL string `env:"BASE_URL" envDefault:"http://localhost:3000"`
Address string `env:"ADDRESS,expand" envDefault:":3000"`
Session Session `envPrefix:"SESSION_"`
}
type Session struct {
Keys []string `env:"KEYS"`
Cookie Cookie `envPrefix:"COOKIE_"`
}
type Cookie struct {
Path string `env:"PATH,expand" envDefault:"/"`
HTTPOnly bool `env:"HTTP_ONLY,expand" envDefault:"true"`
Secure bool `env:"SECURE,expand" envDefault:"false"`
MaxAge time.Duration `env:"MAX_AGE,expand" envDefault:"24h"`
}

12
internal/config/llm.go Normal file
View File

@ -0,0 +1,12 @@
package config
type LLM struct {
Provider LLMProvider `envPrefix:"PROVIDER_"`
}
type LLMProvider struct {
Name string `env:"NAME" envDefault:"openai"`
BaseURL string `env:"BASE_URL" envDefault:"https://api.openai.com/v1/"`
Key string `env:"KEY"`
Model string `env:"MODEL" envDefault:"gpt-4o-mini"`
}

View File

@ -0,0 +1,5 @@
package config
type Logger struct {
Level int `env:"LEVEL" envDefault:"0"`
}

View File

@ -0,0 +1,19 @@
package config
type Storage struct {
Database Database `envPrefix:"DATABASE_"`
Object Object `envPrefix:"OBJECT_"`
}
type Database struct {
DSN string `env:"DSN" envDefault:"sqlite://data.sqlite"`
}
type Object struct {
DSN string `env:"DSN" envDefault:"sqlite://data.sqlite"`
Encryption Encryption `envPrefix:"ENCRYPTION_"`
}
type Encryption struct {
Key string `env:"KEY,unset"`
}

View File

@ -0,0 +1,50 @@
package workflow
import (
"strconv"
"strings"
)
type CompensationError struct {
executionErr error
compensationErrs []error
}
func (e *CompensationError) ExecutionError() error {
return e.executionErr
}
func (e *CompensationError) CompensationErrors() []error {
return e.compensationErrs
}
func (e *CompensationError) Error() string {
var sb strings.Builder
sb.WriteString("compensation error: ")
sb.WriteString("execution error '")
sb.WriteString(e.ExecutionError().Error())
sb.WriteString("' resulted in following compensation errors: ")
for idx, err := range e.CompensationErrors() {
if idx > 0 {
sb.WriteString(", ")
}
sb.WriteString("[")
sb.WriteString(strconv.FormatInt(int64(idx), 10))
sb.WriteString("] ")
sb.WriteString(err.Error())
}
return sb.String()
}
func NewCompensationError(executionErr error, compensationErrs ...error) *CompensationError {
return &CompensationError{
executionErr: executionErr,
compensationErrs: compensationErrs,
}
}
var _ error = &CompensationError{}

View File

@ -0,0 +1,40 @@
package workflow
import "context"
type Step interface {
Execute(ctx context.Context) error
Compensate(ctx context.Context) error
}
type step struct {
execute func(ctx context.Context) error
compensate func(ctx context.Context) error
}
// Compensate implements Step.
func (s *step) Compensate(ctx context.Context) error {
if s.compensate == nil {
return nil
}
return s.compensate(ctx)
}
// Execute implements Step.
func (s *step) Execute(ctx context.Context) error {
if s.execute == nil {
return nil
}
return s.execute(ctx)
}
var _ Step = &step{}
func StepFunc(execute func(ctx context.Context) error, compensate func(ctx context.Context) error) Step {
return &step{
execute: execute,
compensate: compensate,
}
}

View File

@ -0,0 +1,46 @@
package workflow
import (
"context"
"github.com/pkg/errors"
)
type Workflow struct {
steps []Step
}
func (w *Workflow) Execute(ctx context.Context) error {
for idx, step := range w.steps {
if executionErr := step.Execute(ctx); executionErr != nil {
if compensationErrs := w.compensate(ctx, idx-1); compensationErrs != nil {
return errors.WithStack(NewCompensationError(executionErr, compensationErrs...))
}
return errors.WithStack(executionErr)
}
}
return nil
}
func (w *Workflow) compensate(ctx context.Context, fromIndex int) []error {
errs := make([]error, 0)
for idx := fromIndex; idx >= 0; idx -= 1 {
act := w.steps[idx]
if err := act.Compensate(ctx); err != nil {
errs = append(errs, errors.WithStack(err))
}
}
if len(errs) > 0 {
return errs
}
return nil
}
func New(steps ...Step) *Workflow {
return &Workflow{steps: steps}
}

22
internal/crypto/rand.go Normal file
View File

@ -0,0 +1,22 @@
package crypto
import (
"crypto/rand"
"github.com/pkg/errors"
)
func RandomBytes(size int) ([]byte, error) {
data := make([]byte, size)
read, err := rand.Read(data)
if err != nil {
return nil, errors.WithStack(err)
}
if read != size {
return nil, errors.New("unexpected number of read bytes")
}
return data, nil
}

View File

@ -0,0 +1,28 @@
package context
import (
"context"
"net/url"
"github.com/pkg/errors"
)
const keyBaseURL = "baseURL"
func BaseURL(ctx context.Context) *url.URL {
rawBaseURL, ok := ctx.Value(keyBaseURL).(string)
if !ok {
panic(errors.New("no base url in context"))
}
baseURL, err := url.Parse(rawBaseURL)
if err != nil {
panic(errors.WithStack(err))
}
return baseURL
}
func SetBaseURL(ctx context.Context, baseURL string) context.Context {
return context.WithValue(ctx, keyBaseURL, baseURL)
}

View File

@ -0,0 +1,3 @@
package context
type key string

View File

@ -0,0 +1,23 @@
package context
import (
"context"
"net/url"
"github.com/pkg/errors"
)
const keyCurrentURL = "currentURL"
func CurrentURL(ctx context.Context) *url.URL {
currentURL, ok := ctx.Value(keyCurrentURL).(*url.URL)
if !ok {
panic(errors.New("no current url in context"))
}
return currentURL
}
func SetCurrentURL(ctx context.Context, u *url.URL) context.Context {
return context.WithValue(ctx, keyCurrentURL, u)
}

View File

@ -0,0 +1,23 @@
package context
import (
"context"
"github.com/markbates/goth"
"github.com/pkg/errors"
)
const keyUser = "user"
func User(ctx context.Context) *goth.User {
user, ok := ctx.Value(keyUser).(*goth.User)
if !ok {
panic(errors.New("no user in context"))
}
return user
}
func SetUser(ctx context.Context, user *goth.User) context.Context {
return context.WithValue(ctx, keyUser, user)
}

View File

@ -0,0 +1,9 @@
package form
import "errors"
var (
ErrFieldNotFound = errors.New("field not found")
ErrUnexpectedValue = errors.New("unexpected value")
ErrMissingValue = errors.New("missing value")
)

View File

@ -0,0 +1,85 @@
package form
import (
"github.com/pkg/errors"
)
type Attrs map[string]any
type FieldUnmarshaller interface {
Unmarshal(v string) error
}
type ValidationFunc func(f *Field) error
type Field struct {
name string
attrs map[string]any
validators []ValidationFunc
}
func (f *Field) Name() string {
return f.name
}
func (f *Field) Get(key string) (value any, exists bool) {
value, exists = f.attrs[key]
return
}
func (f *Field) Set(key string, value any) {
f.attrs[key] = value
}
func (f *Field) Del(key string) {
delete(f.attrs, key)
}
func (f *Field) Attrs() map[string]any {
return f.attrs
}
func (f *Field) Validate() error {
for _, fn := range f.validators {
if err := fn(f); err != nil {
return errors.WithStack(err)
}
}
return nil
}
func NewField(name string, attrs Attrs, validators ...ValidationFunc) *Field {
return &Field{
name: name,
attrs: attrs,
validators: validators,
}
}
func FormFieldAttr[T any](f *Form, name string, attr string) (T, error) {
field := f.Field(name)
if field == nil {
return *new(T), errors.WithStack(ErrFieldNotFound)
}
value, err := FieldAttr[T](field, attr)
if err != nil {
return *new(T), errors.WithStack(err)
}
return value, nil
}
func FieldAttr[T any](f *Field, name string) (T, error) {
raw, ok := f.Get(name)
if !ok {
return *new(T), errors.WithStack(ErrMissingValue)
}
value, ok := raw.(T)
if !ok {
return *new(T), errors.WithStack(ErrUnexpectedValue)
}
return value, nil
}

131
internal/http/form/form.go Normal file
View File

@ -0,0 +1,131 @@
package form
import (
"mime/multipart"
"net/http"
"net/url"
"github.com/pkg/errors"
)
type ValidationErrors map[string]error
type Form struct {
fields []*Field
errors ValidationErrors
}
func (f *Form) Handle(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return errors.WithStack(err)
}
if err := f.Parse(r.Form); err != nil {
return errors.WithStack(err)
}
return nil
}
func (f *Form) HandleMultipart(r *http.Request, maxMemory int64) error {
if err := r.ParseMultipartForm(maxMemory); err != nil {
return errors.WithStack(err)
}
if err := f.ParseMultipart(r.MultipartForm); err != nil {
return errors.WithStack(err)
}
return nil
}
func (f *Form) ParseMultipart(multi *multipart.Form) error {
for _, f := range f.fields {
name := f.Name()
values, exists := multi.Value[name]
if !exists {
f.Del("value")
} else {
f.Set("value", values)
}
files, exists := multi.File[name]
if !exists {
f.Del("file")
} else {
f.Set("file", files)
}
}
return nil
}
func (f *Form) Parse(values url.Values) error {
for _, f := range f.fields {
name := f.Name()
if !values.Has(name) {
f.Del("value")
continue
}
value := values.Get(name)
f.Set("value", value)
}
return nil
}
func (f *Form) Validate() ValidationErrors {
var errs ValidationErrors
for _, f := range f.fields {
if err := f.Validate(); err != nil {
if errs == nil {
errs = make(ValidationErrors)
}
errs[f.Name()] = err
}
}
f.errors = errs
return errs
}
func (f *Form) Field(name string) *Field {
for _, field := range f.fields {
if field.Name() == name {
return field
}
}
return nil
}
func (f *Form) Error(name string) (ValidationError, bool) {
err, exists := f.errors[name]
if !exists {
return nil, false
}
var validationErr ValidationError
if errors.As(err, &validationErr) {
return validationErr, true
}
return NewValidationError("Unexpected error"), true
}
func (f *Form) Extend(fields ...*Field) *Form {
fields = append(f.fields, fields...)
return New(fields...)
}
func New(fields ...*Field) *Form {
return &Form{
fields: fields,
errors: make(ValidationErrors),
}
}

View File

@ -0,0 +1,27 @@
package form
import (
"regexp"
"github.com/pkg/errors"
)
func MatchRegExp(pattern string, message string) func(f *Field) error {
return func(f *Field) error {
value, err := FieldAttr[string](f, "value")
if err != nil {
return errors.WithStack(err)
}
matches, err := regexp.MatchString(pattern, value)
if err != nil {
return errors.WithStack(err)
}
if !matches {
return NewValidationError(message)
}
return nil
}
}

View File

@ -0,0 +1,22 @@
package form
import (
"strings"
"github.com/pkg/errors"
)
func NonEmpty(message string) func(f *Field) error {
return func(f *Field) error {
value, err := FieldAttr[string](f, "value")
if err != nil {
return errors.WithStack(err)
}
if strings.TrimSpace(value) == "" {
return NewValidationError(message)
}
return nil
}
}

View File

@ -0,0 +1,26 @@
package form
type ValidationError interface {
error
Message() string
}
type validationError struct {
message string
}
// Error implements ValidationError.
func (v *validationError) Error() string {
return "validation error"
}
// Message implements ValidationError.
func (v *validationError) Message() string {
return v.message
}
var _ ValidationError = &validationError{}
func NewValidationError(message string) ValidationError {
return &validationError{message: message}
}

View File

@ -0,0 +1,34 @@
package component
import common "forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/common/component"
type LoginPageVModel struct {
Providers []ProviderVModel
}
type ProviderVModel struct {
ID string
Label string
Icon string
}
templ LoginPage(vmodel LoginPageVModel) {
@common.Page() {
<div class="is-flex is-justify-content-center is-align-items-center is-fullheight">
<nav class="panel is-link" style="min-width: 33%">
<p class="panel-heading">
<div class="title">ClearCase</div>
<span>&nbsp;- choose your provider</span>
</p>
for _, provider := range vmodel.Providers {
<a class="panel-block" href={ templ.URL("/auth/providers/" + provider.ID) } hx-boost="false">
<span class="panel-icon is-size-3">
<i class={ "fab", provider.Icon } aria-hidden="true"></i>
</span>
{ provider.Label }
</a>
}
</nav>
</div>
}
}

View File

@ -0,0 +1,124 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.819
package component
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import common "forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/common/component"
type LoginPageVModel struct {
Providers []ProviderVModel
}
type ProviderVModel struct {
ID string
Label string
Icon string
}
func LoginPage(vmodel LoginPageVModel) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"is-flex is-justify-content-center is-align-items-center is-fullheight\"><nav class=\"panel is-link\" style=\"min-width: 33%\"><p class=\"panel-heading\"><div class=\"title\">ClearCase</div><span>&nbsp;- choose your provider</span></p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, provider := range vmodel.Providers {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a class=\"panel-block\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL = templ.URL("/auth/providers/" + provider.ID)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var3)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" hx-boost=\"false\"><span class=\"panel-icon is-size-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 = []any{"fab", provider.Icon}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<i class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/auth/component/login_page.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" aria-hidden=\"true\"></i></span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(provider.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/auth/component/login_page.templ`, Line: 28, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</nav></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = common.Page().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -0,0 +1,55 @@
package auth
import (
"context"
"net/http"
"forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/auth/component"
"github.com/gorilla/sessions"
)
type Provider = component.ProviderVModel
type Handler struct {
mux *http.ServeMux
sessionStore sessions.Store
sessionName string
providers []Provider
defaultAdmin *DefaultAdmin
}
// ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.mux.ServeHTTP(w, r)
}
func NewHandler(sessionStore sessions.Store, funcs ...OptionFunc) *Handler {
opts := NewOptions(funcs...)
h := &Handler{
mux: http.NewServeMux(),
sessionStore: sessionStore,
sessionName: opts.SessionName,
providers: opts.Providers,
defaultAdmin: opts.DefaultAdmin,
}
h.mux.HandleFunc("GET /login", h.getLoginPage)
h.mux.Handle("GET /providers/{provider}", withContextProvider(http.HandlerFunc(h.handleProvider)))
h.mux.Handle("GET /providers/{provider}/callback", withContextProvider(http.HandlerFunc(h.handleProviderCallback)))
h.mux.HandleFunc("GET /logout", h.handleLogout)
h.mux.Handle("GET /providers/{provider}/logout", withContextProvider(http.HandlerFunc(h.handleProviderLogout)))
return h
}
var _ http.Handler = &Handler{}
func withContextProvider(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
provider := r.PathValue("provider")
r = r.WithContext(context.WithValue(r.Context(), "provider", provider))
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}

View File

@ -0,0 +1,16 @@
package auth
import (
"net/http"
"forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/auth/component"
"github.com/a-h/templ"
)
func (h *Handler) getLoginPage(w http.ResponseWriter, r *http.Request) {
vmodel := component.LoginPageVModel{
Providers: h.providers,
}
login := component.LoginPage(vmodel)
templ.Handler(login).ServeHTTP(w, r)
}

View File

@ -0,0 +1,33 @@
package auth
import (
"log/slog"
"net/http"
"forge.cadoles.com/wpetit/clearcase/internal/http/context"
"github.com/pkg/errors"
)
var ErrUserNotFound = errors.New("user not found")
func (h *Handler) Middleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
user, err := h.retrieveSessionUser(r)
if err != nil {
slog.ErrorContext(r.Context(), "could not retrieve user from session", slog.Any("error", errors.WithStack(err)))
http.Redirect(w, r, "/auth/login", http.StatusTemporaryRedirect)
return
}
ctx := r.Context()
ctx = context.SetUser(ctx, user)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}

View File

@ -0,0 +1,45 @@
package auth
type DefaultAdmin struct {
Provider string
Email string
}
type Options struct {
Providers []Provider
DefaultAdmin *DefaultAdmin
SessionName string
}
type OptionFunc func(opts *Options)
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
Providers: make([]Provider, 0),
SessionName: "rkvst_auth",
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithProviders(providers ...Provider) OptionFunc {
return func(opts *Options) {
opts.Providers = providers
}
}
func WithDefaultAdmin(defaultAdmin DefaultAdmin) OptionFunc {
return func(opts *Options) {
opts.DefaultAdmin = &defaultAdmin
}
}
func WithSessionName(sessionName string) OptionFunc {
return func(opts *Options) {
opts.SessionName = sessionName
}
}

View File

@ -0,0 +1,71 @@
package auth
import (
"fmt"
"log/slog"
"net/http"
"forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/common"
"github.com/markbates/goth/gothic"
"github.com/pkg/errors"
)
func (h *Handler) handleProvider(w http.ResponseWriter, r *http.Request) {
if _, err := gothic.CompleteUserAuth(w, r); err == nil {
http.Redirect(w, r, "/auth/logout", http.StatusTemporaryRedirect)
} else {
gothic.BeginAuthHandler(w, r)
}
}
func (h *Handler) handleProviderCallback(w http.ResponseWriter, r *http.Request) {
user, err := gothic.CompleteUserAuth(w, r)
if err != nil {
slog.ErrorContext(r.Context(), "could not complete user auth", slog.Any("error", errors.WithStack(err)))
http.Redirect(w, r, "/auth/logout", http.StatusTemporaryRedirect)
return
}
ctx := r.Context()
slog.DebugContext(ctx, "authenticated user", slog.Any("user", user))
if err := h.storeSessionUser(w, r, &user); err != nil {
slog.ErrorContext(r.Context(), "could not store session user", slog.Any("error", errors.WithStack(err)))
http.Redirect(w, r, "/auth/logout", http.StatusTemporaryRedirect)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
user, err := h.retrieveSessionUser(r)
if err != nil && !errors.Is(err, errSessionNotFound) {
common.HandleError(w, r, errors.WithStack(err))
return
}
if err := h.clearSession(w, r); err != nil && !errors.Is(err, errSessionNotFound) {
common.HandleError(w, r, errors.WithStack(err))
return
}
if user == nil {
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
redirectURL := fmt.Sprintf("/auth/providers/%s/logout", user.Provider)
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
}
func (h *Handler) handleProviderLogout(w http.ResponseWriter, r *http.Request) {
if err := gothic.Logout(w, r); err != nil {
common.HandleError(w, r, errors.WithStack(err))
return
}
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}

View File

@ -0,0 +1,73 @@
package auth
import (
"log/slog"
"net/http"
"github.com/gorilla/sessions"
"github.com/markbates/goth"
"github.com/pkg/errors"
)
const userAttr = "u"
const tenantIDAttr = "t"
var errSessionNotFound = errors.New("session not found")
func (h *Handler) storeSessionUser(w http.ResponseWriter, r *http.Request, user *goth.User) error {
sess, err := h.getSession(r)
if err != nil {
return errors.WithStack(err)
}
sess.Values[userAttr] = user
if err := sess.Save(r, w); err != nil {
return errors.WithStack(err)
}
return nil
}
func (h *Handler) retrieveSessionUser(r *http.Request) (*goth.User, error) {
sess, err := h.getSession(r)
if err != nil {
return nil, errors.WithStack(err)
}
user, ok := sess.Values[userAttr].(*goth.User)
if !ok {
return nil, errors.WithStack(errSessionNotFound)
}
return user, nil
}
func (h *Handler) getSession(r *http.Request) (*sessions.Session, error) {
sess, err := h.sessionStore.Get(r, h.sessionName)
if err != nil {
slog.ErrorContext(r.Context(), "could not retrieve session from store", slog.Any("error", errors.WithStack(err)))
return sess, errors.WithStack(errSessionNotFound)
}
return sess, nil
}
func (h *Handler) clearSession(w http.ResponseWriter, r *http.Request) error {
sess, err := h.getSession(r)
if err != nil && !errors.Is(err, errSessionNotFound) {
return errors.WithStack(err)
}
if sess == nil {
return nil
}
sess.Options.MaxAge = -1
if err := sess.Save(r, w); err != nil {
return errors.WithStack(err)
}
return nil
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,165 @@
Fonticons, Inc. (https://fontawesome.com)
--------------------------------------------------------------------------------
Font Awesome Free License
Font Awesome Free is free, open source, and GPL friendly. You can use it for
commercial projects, open source projects, or really almost whatever you want.
Full Font Awesome Free license: https://fontawesome.com/license/free.
--------------------------------------------------------------------------------
# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/)
The Font Awesome Free download is licensed under a Creative Commons
Attribution 4.0 International License and applies to all icons packaged
as SVG and JS file types.
--------------------------------------------------------------------------------
# Fonts: SIL OFL 1.1 License
In the Font Awesome Free download, the SIL OFL license applies to all icons
packaged as web and desktop font files.
Copyright (c) 2024 Fonticons, Inc. (https://fontawesome.com)
with Reserved Font Name: "Font Awesome".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
SIL OPEN FONT LICENSE
Version 1.1 - 26 February 2007
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting — in part or in whole — any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
--------------------------------------------------------------------------------
# Code: MIT License (https://opensource.org/licenses/MIT)
In the Font Awesome Free download, the MIT license applies to all non-font and
non-icon files.
Copyright 2024 Fonticons, Inc.
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.
--------------------------------------------------------------------------------
# Attribution
Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font
Awesome Free files already contain embedded comments with sufficient
attribution, so you shouldn't need to do anything additional when using these
files normally.
We've kept attribution comments terse, so we ask that you do not actively work
to remove them from files, especially code. They're a great way for folks to
learn about Font Awesome.
--------------------------------------------------------------------------------
# Brand Icons
All brand icons are trademarks of their respective owners. The use of these
trademarks does not indicate endorsement of the trademark holder by Font
Awesome, nor vice versa. **Please do not use brand logos for any purpose except
to represent the company, product, or service to which they refer.**

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,18 @@
html,
body {
height: 100%;
}
.is-fullwidth {
min-width: 100%;
}
.is-fullheight {
min-height: 100%;
height: 100%;
}
.has-text-ellipsis {
overflow-x: hidden;
text-overflow: ellipsis;
}

View File

@ -0,0 +1,20 @@
package component
type ErrorPageVModel struct {
Message string
}
templ ErrorPage(vmodel ErrorPageVModel) {
@Page(WithTitle("Error")) {
<div class="is-flex is-justify-content-center is-align-items-center is-fullheight">
<article class="message is-danger">
<div class="message-header">
<p>Error</p>
</div>
<div class="message-body">
{ vmodel.Message }
</div>
</article>
</div>
}
}

View File

@ -0,0 +1,75 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.819
package component
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
type ErrorPageVModel struct {
Message string
}
func ErrorPage(vmodel ErrorPageVModel) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"is-flex is-justify-content-center is-align-items-center is-fullheight\"><article class=\"message is-danger\"><div class=\"message-header\"><p>Error</p></div><div class=\"message-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(vmodel.Message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/error_page.templ`, Line: 15, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></article></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Page(WithTitle("Error")).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -0,0 +1,94 @@
package component
import (
"errors"
"forge.cadoles.com/wpetit/clearcase/internal/http/form"
)
templ FormField(form *form.Form, id string, name string, label string) {
{{ field := form.Field(name) }}
if field != nil {
<div class="field">
{{ err, hasErr := form.Error(name) }}
<label class={ "label", templ.KV("has-text-danger", hasErr) } for={ id }>{ label }</label>
<div class="control">
<input class={ "input", templ.KV("is-danger", hasErr) } id={ id } name={ field.Name() } { field.Attrs()... }/>
if hasErr {
<p class="help is-danger">{ err.Message() }</p>
}
</div>
</div>
}
}
templ FormTextarea(form *form.Form, id string, name string, label string) {
{{ field := form.Field(name) }}
if field != nil {
<div class="field">
<label class="label" for={ id }>{ label }</label>
<div class="control">
{{ err, hasErr := form.Error(name) }}
{{ value, hasValue := field.Get("value") }}
<textarea class={ "textarea", templ.KV("is-danger", hasErr) } id={ id } name={ field.Name() } { field.Attrs()... }>
if hasValue {
{ value.(string) }
}
</textarea>
if hasErr {
<p class="help is-danger">{ err.Message() }</p>
}
</div>
</div>
}
}
templ FormSelect(form *form.Form, id string, name string, label string, kvOptions ...string) {
{{ field := form.Field(name) }}
if field != nil {
<div class="field">
<label class="label" for={ id }>{ label }</label>
<div class="control">
{{ err, hasErr := form.Error(name) }}
<div class="select is-fullwidth">
<select id={ id } name={ field.Name() } { field.Attrs()... }>
{{ options := keyValuesToOptions(kvOptions) }}
for _, o := range options {
<option value={ o.Value }>{ o.Label }</option>
}
</select>
</div>
if hasErr {
<p class="help is-danger">{ err.Message() }</p>
}
</div>
</div>
}
}
type SelectOption struct {
Value string
Label string
}
func keyValuesToOptions(kv []string) []SelectOption {
if len(kv)%2 != 0 {
panic(errors.New("expected pair number of key/values"))
}
options := make([]SelectOption, 0)
var key string
for idx := range kv {
if idx%2 == 0 {
key = kv[idx]
continue
}
options = append(options, SelectOption{
Value: kv[idx],
Label: key,
})
}
return options
}

View File

@ -0,0 +1,515 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.819
package component
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"errors"
"forge.cadoles.com/wpetit/clearcase/internal/http/form"
)
func FormField(form *form.Form, id string, name string, label string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
field := form.Field(name)
if field != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"field\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
err, hasErr := form.Error(name)
var templ_7745c5c3_Var2 = []any{"label", templ.KV("has-text-danger", hasErr)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<label class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" for=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 13, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 13, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</label><div class=\"control\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 = []any{"input", templ.KV("is-danger", hasErr)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<input class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 15, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(field.Name())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 15, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, field.Attrs())
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hasErr {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<p class=\"help is-danger\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(err.Message())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 17, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func FormTextarea(form *form.Form, id string, name string, label string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil {
templ_7745c5c3_Var11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
field := form.Field(name)
if field != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"field\"><label class=\"label\" for=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 28, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 28, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</label><div class=\"control\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
err, hasErr := form.Error(name)
value, hasValue := field.Get("value")
var templ_7745c5c3_Var14 = []any{"textarea", templ.KV("is-danger", hasErr)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<textarea class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var14).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 32, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(field.Name())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 32, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, field.Attrs())
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hasValue {
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(value.(string))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 34, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</textarea> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hasErr {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<p class=\"help is-danger\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(err.Message())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 38, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func FormSelect(form *form.Form, id string, name string, label string, kvOptions ...string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var20 := templ.GetChildren(ctx)
if templ_7745c5c3_Var20 == nil {
templ_7745c5c3_Var20 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
field := form.Field(name)
if field != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"field\"><label class=\"label\" for=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 49, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 49, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</label><div class=\"control\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
err, hasErr := form.Error(name)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"select is-fullwidth\"><select id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 53, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(field.Name())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 53, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, field.Attrs())
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
options := keyValuesToOptions(kvOptions)
for _, o := range options {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(o.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 56, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(o.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 56, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hasErr {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<p class=\"help is-danger\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(err.Message())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/field.templ`, Line: 61, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
type SelectOption struct {
Value string
Label string
}
func keyValuesToOptions(kv []string) []SelectOption {
if len(kv)%2 != 0 {
panic(errors.New("expected pair number of key/values"))
}
options := make([]SelectOption, 0)
var key string
for idx := range kv {
if idx%2 == 0 {
key = kv[idx]
continue
}
options = append(options, SelectOption{
Value: kv[idx],
Label: key,
})
}
return options
}
var _ = templruntime.GeneratedTemplate

View File

@ -0,0 +1,68 @@
package component
type PageOptions struct {
Title string
Head func() templ.Component
}
type PageOptionFunc func(opts *PageOptions)
func WithTitle(title string) PageOptionFunc {
return func(opts *PageOptions) {
if title != "" {
opts.Title = title + " | Rkvst"
} else {
opts.Title = "Rkvst"
}
}
}
func WithHead(fn func() templ.Component) PageOptionFunc {
return func(opts *PageOptions) {
opts.Head = fn
}
}
func NewPageOptions(funcs ...PageOptionFunc) *PageOptions {
opts := &PageOptions{
Title: "",
Head: nil,
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
templ Page(funcs ...PageOptionFunc) {
{{ opts := NewPageOptions(funcs...) }}
<!DOCTYPE html>
<html data-theme="dark">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ opts.Title }</title>
<link rel="icon" type="image/x-icon" href={ string(BaseURL(ctx, WithPath("/assets/favicon.ico"))) }/>
<link rel="stylesheet" href={ string(BaseURL(ctx, WithPath("/assets/bulma.min.css"))) }/>
<link rel="stylesheet" href={ string(BaseURL(ctx, WithPath("/assets/fontawesome/css/all.min.css"))) }/>
<link rel="stylesheet" href={ string(BaseURL(ctx, WithPath("/assets/style.css"))) }/>
<script src={ string(BaseURL(ctx, WithPath("/assets/htmx.min.js"))) }></script>
if opts.Head != nil {
@opts.Head()
}
</head>
<body hx-boost="true" class="is-fullheight">
<div id="main" class="is-fullheight">
{ children... }
</div>
<script type="text/javascript">
htmx.config.responseHandling = [
{code:"204", swap: false}, // 204 - No Content by default does nothing, but is not an error
{code:"[23]..", swap: true}, // 200 & 300 responses are non-errors and are swapped
{code:"[45]..", swap: true, error:true}, // 400 & 500 responses are not swapped and are errors
];
</script>
</body>
</html>
}

View File

@ -0,0 +1,172 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.819
package component
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
type PageOptions struct {
Title string
Head func() templ.Component
}
type PageOptionFunc func(opts *PageOptions)
func WithTitle(title string) PageOptionFunc {
return func(opts *PageOptions) {
if title != "" {
opts.Title = title + " | Rkvst"
} else {
opts.Title = "Rkvst"
}
}
}
func WithHead(fn func() templ.Component) PageOptionFunc {
return func(opts *PageOptions) {
opts.Head = fn
}
}
func NewPageOptions(funcs ...PageOptionFunc) *PageOptions {
opts := &PageOptions{
Title: "",
Head: nil,
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func Page(funcs ...PageOptionFunc) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
opts := NewPageOptions(funcs...)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html data-theme=\"dark\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(opts.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/page.templ`, Line: 45, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"icon\" type=\"image/x-icon\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(string(BaseURL(ctx, WithPath("/assets/favicon.ico"))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/page.templ`, Line: 46, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"><link rel=\"stylesheet\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(string(BaseURL(ctx, WithPath("/assets/bulma.min.css"))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/page.templ`, Line: 47, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"><link rel=\"stylesheet\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(string(BaseURL(ctx, WithPath("/assets/fontawesome/css/all.min.css"))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/page.templ`, Line: 48, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"><link rel=\"stylesheet\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(string(BaseURL(ctx, WithPath("/assets/style.css"))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/page.templ`, Line: 49, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"><script src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(string(BaseURL(ctx, WithPath("/assets/htmx.min.js"))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/http/handler/webui/common/component/page.templ`, Line: 50, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"></script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if opts.Head != nil {
templ_7745c5c3_Err = opts.Head().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</head><body hx-boost=\"true\" class=\"is-fullheight\"><div id=\"main\" class=\"is-fullheight\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><script type=\"text/javascript\">\n\t\t\t\thtmx.config.responseHandling = [\n\t\t\t\t\t{code:\"204\", swap: false}, // 204 - No Content by default does nothing, but is not an error\n\t\t\t\t\t{code:\"[23]..\", swap: true}, // 200 & 300 responses are non-errors and are swapped\n\t\t\t\t\t{code:\"[45]..\", swap: true, error:true}, // 400 & 500 responses are not swapped and are errors\n\t\t\t\t];\n\t\t\t</script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -0,0 +1,38 @@
package component
import (
"context"
httpCtx "forge.cadoles.com/wpetit/clearcase/internal/http/context"
"forge.cadoles.com/wpetit/clearcase/internal/http/url"
"github.com/a-h/templ"
)
var (
WithPath = url.WithPath
WithoutValues = url.WithoutValues
WithValuesReset = url.WithValuesReset
WithValues = url.WithValues
)
func BaseURL(ctx context.Context, funcs ...url.MutationFunc) templ.SafeURL {
baseURL := httpCtx.BaseURL(ctx)
mutated := url.Mutate(baseURL, funcs...)
return templ.SafeURL(mutated.String())
}
func CurrentURL(ctx context.Context, funcs ...url.MutationFunc) templ.SafeURL {
currentURL := clone(httpCtx.CurrentURL(ctx))
mutated := url.Mutate(currentURL, funcs...)
return templ.SafeURL(mutated.String())
}
func MatchPath(ctx context.Context, path string) bool {
currentURL := httpCtx.CurrentURL(ctx)
return currentURL.Path == path
}
func clone[T any](v *T) *T {
copy := *v
return &copy
}

View File

@ -0,0 +1,29 @@
package common
type Error struct {
err string
userMessage string
statusCode int
}
// StatusCode implements HTTPError.
func (e *Error) StatusCode() int {
return e.statusCode
}
// Error implements UserFacingError.
func (e *Error) Error() string {
return e.err
}
// UserMessage implements UserFacingError.
func (e *Error) UserMessage() string {
return e.userMessage
}
func NewError(err string, userMessage string, statusCode int) *Error {
return &Error{err, userMessage, statusCode}
}
var _ UserFacingError = &Error{}
var _ HTTPError = &Error{}

View File

@ -0,0 +1,48 @@
package common
import (
"log/slog"
"net/http"
"forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/common/component"
"github.com/a-h/templ"
"github.com/pkg/errors"
)
type HTTPError interface {
error
StatusCode() int
}
type UserFacingError interface {
error
UserMessage() string
}
func HandleError(w http.ResponseWriter, r *http.Request, err error) {
vmodel := component.ErrorPageVModel{}
statusCode := http.StatusInternalServerError
var httpErr HTTPError
if errors.As(err, &httpErr) {
statusCode = httpErr.StatusCode()
}
w.WriteHeader(statusCode)
var userFacingErr UserFacingError
if errors.As(err, &userFacingErr) {
vmodel.Message = userFacingErr.UserMessage()
} else {
vmodel.Message = http.StatusText(statusCode)
}
if httpErr == nil && userFacingErr == nil {
slog.ErrorContext(r.Context(), "unexpected error", slog.Any("error", errors.WithStack(err)))
}
errorPage := component.ErrorPage(vmodel)
templ.Handler(errorPage).ServeHTTP(w, r)
}

View File

@ -0,0 +1,36 @@
package common
import (
"embed"
"io/fs"
"net/http"
)
//go:embed assets/*
var assetsFS embed.FS
type Handler struct {
mux *http.ServeMux
}
// ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.mux.ServeHTTP(w, r)
}
func NewHandler() *Handler {
handler := &Handler{
mux: http.NewServeMux(),
}
assets, err := fs.Sub(assetsFS, "assets")
if err != nil {
panic(err)
}
handler.mux.Handle("GET /", http.FileServerFS(assets))
return handler
}
var _ http.Handler = &Handler{}

View File

@ -0,0 +1,36 @@
package common
import (
"bytes"
"context"
"io"
"net/http"
"github.com/gabriel-vasile/mimetype"
"github.com/pkg/errors"
)
type viewModelFillerFunc[T any] func(ctx context.Context, vmodel *T, r *http.Request) error
func FillViewModel[T any](ctx context.Context, vmodel *T, r *http.Request, funcs ...viewModelFillerFunc[T]) error {
for _, fn := range funcs {
if err := fn(ctx, vmodel, r); err != nil {
return errors.WithStack(err)
}
}
return nil
}
func DetectMimeType(input io.Reader) (mimeType string, recycled io.Reader, err error) {
header := bytes.NewBuffer(nil)
mtype, err := mimetype.DetectReader(io.TeeReader(input, header))
if err != nil {
return
}
recycled = io.MultiReader(header, input)
return mtype.String(), recycled, err
}

View File

@ -0,0 +1,41 @@
package webui
import (
"net/http"
"strings"
)
type Handler struct {
mux *http.ServeMux
}
// ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.mux.ServeHTTP(w, r)
}
func NewHandler(funcs ...OptionFunc) *Handler {
opts := NewOptions(funcs...)
h := &Handler{
mux: http.NewServeMux(),
}
for mountpoint, handler := range opts.Mounts {
h.mount(mountpoint, handler)
}
return h
}
func (h *Handler) mount(prefix string, handler http.Handler) {
trimmed := strings.TrimSuffix(prefix, "/")
if len(trimmed) > 0 {
h.mux.Handle(prefix, http.StripPrefix(trimmed, handler))
} else {
h.mux.Handle(prefix, handler)
}
}
var _ http.Handler = &Handler{}

View File

@ -0,0 +1,8 @@
package component
import common "forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/common/component"
templ IssuePage(title string) {
@common.Page(common.WithTitle(title)) {
}
}

View File

@ -0,0 +1,56 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.819
package component
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import common "forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/common/component"
func IssuePage(title string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
return nil
})
templ_7745c5c3_Err = common.Page(common.WithTitle(title)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -0,0 +1,26 @@
package issue
import (
"net/http"
)
type Handler struct {
mux *http.ServeMux
}
// ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.mux.ServeHTTP(w, r)
}
func NewHandler() *Handler {
h := &Handler{
mux: http.NewServeMux(),
}
h.mux.HandleFunc("GET /", h.getIssuePage)
return h
}
var _ http.Handler = &Handler{}

View File

@ -0,0 +1,7 @@
package issue
import "net/http"
func (h *Handler) getIssuePage(w http.ResponseWriter, r *http.Request) {
}

View File

@ -0,0 +1,38 @@
package webui
import (
"net/http"
"github.com/gorilla/sessions"
)
type Options struct {
BaseURL string
SessionStore sessions.Store
Mounts map[string]http.Handler
}
type OptionFunc func(opts *Options)
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
BaseURL: "",
Mounts: make(map[string]http.Handler),
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithBaseURL(baseURL string) OptionFunc {
return func(opts *Options) {
opts.BaseURL = baseURL
}
}
func WithMount(mountpoint string, handler http.Handler) OptionFunc {
return func(opts *Options) {
opts.Mounts[mountpoint] = handler
}
}

41
internal/http/options.go Normal file
View File

@ -0,0 +1,41 @@
package http
import "github.com/gorilla/sessions"
type Options struct {
Address string
BaseURL string
SessionStore sessions.Store
}
type OptionFunc func(opts *Options)
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
Address: ":3000",
BaseURL: "",
SessionStore: sessions.NewCookieStore(),
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithBaseURL(baseURL string) OptionFunc {
return func(opts *Options) {
opts.BaseURL = baseURL
}
}
func WithAddress(addr string) OptionFunc {
return func(opts *Options) {
opts.Address = addr
}
}
func WithSessionStore(store sessions.Store) OptionFunc {
return func(opts *Options) {
opts.SessionStore = store
}
}

63
internal/http/server.go Normal file
View File

@ -0,0 +1,63 @@
package http
import (
"context"
"log/slog"
"net/http"
"github.com/pkg/errors"
sloghttp "github.com/samber/slog-http"
httpCtx "forge.cadoles.com/wpetit/clearcase/internal/http/context"
)
type Server struct {
handler http.Handler
opts *Options
}
func (s *Server) Run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
handler := sloghttp.Recovery(s.handler)
handler = sloghttp.New(slog.Default())(handler)
handler = func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = httpCtx.SetBaseURL(ctx, s.opts.BaseURL)
ctx = httpCtx.SetCurrentURL(ctx, r.URL)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}(handler)
server := http.Server{
Addr: s.opts.Address,
Handler: handler,
}
go func() {
<-ctx.Done()
if err := server.Close(); err != nil {
slog.ErrorContext(ctx, "could not close server", slog.Any("error", errors.WithStack(err)))
}
}()
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return errors.WithStack(err)
}
return nil
}
func NewServer(handler http.Handler, funcs ...OptionFunc) *Server {
opts := NewOptions(funcs...)
return &Server{
handler: handler,
opts: opts,
}
}

View File

@ -0,0 +1,110 @@
package url
import (
"errors"
"net/url"
"path/filepath"
"slices"
)
type URL = url.URL
var Parse = url.Parse
func Mutate(u *url.URL, funcs ...MutationFunc) *url.URL {
cloned := clone(u)
for _, fn := range funcs {
fn(cloned)
}
return cloned
}
type MutationFunc func(u *url.URL)
func keyValuesToValues(kv []string) url.Values {
if len(kv)%2 != 0 {
panic(errors.New("expected pair number of key/values"))
}
values := make(url.Values)
var key string
for idx := range kv {
if idx%2 == 0 {
key = kv[idx]
continue
}
values.Add(key, kv[idx])
}
return values
}
func WithValues(kv ...string) MutationFunc {
values := keyValuesToValues(kv)
return func(u *url.URL) {
query := u.Query()
for k, vv := range values {
for _, v := range vv {
query.Add(k, v)
}
}
u.RawQuery = query.Encode()
}
}
func WithValuesReset() MutationFunc {
return func(u *url.URL) {
u.RawQuery = ""
}
}
func WithoutValues(kv ...string) MutationFunc {
toDelete := keyValuesToValues(kv)
return func(u *url.URL) {
query := u.Query()
for keyToDelete, valuesToDelete := range toDelete {
values, keyExists := query[keyToDelete]
if !keyExists {
continue
}
for _, d := range valuesToDelete {
if d == "*" {
query.Del(keyToDelete)
break
}
query[keyToDelete] = slices.DeleteFunc(values, func(value string) bool {
return value == d
})
}
}
u.RawQuery = query.Encode()
}
}
func WithPath(paths ...string) MutationFunc {
return func(u *url.URL) {
u.Path = filepath.Join(paths...)
}
}
func applyURLMutations(u *url.URL, funcs []MutationFunc) {
for _, fn := range funcs {
fn(u)
}
}
func clone[T any](v *T) *T {
copy := *v
return &copy
}

View File

@ -0,0 +1,138 @@
package setup
import (
"context"
"crypto/rand"
"fmt"
"forge.cadoles.com/wpetit/clearcase/internal/config"
"forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/auth"
"github.com/gorilla/sessions"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/github"
"github.com/markbates/goth/providers/google"
"github.com/markbates/goth/providers/openidConnect"
"github.com/pkg/errors"
)
func NewAuthHandlerFromConfig(ctx context.Context, conf *config.Config) (*auth.Handler, error) {
// Configure sessions store
keyPairs := make([][]byte, 0)
if len(conf.HTTP.Session.Keys) == 0 {
key, err := getRandomBytes(32)
if err != nil {
return nil, errors.Wrap(err, "could not generate cookie signing key")
}
keyPairs = append(keyPairs, key)
} else {
for _, k := range conf.HTTP.Session.Keys {
keyPairs = append(keyPairs, []byte(k))
}
}
sessionStore := sessions.NewCookieStore(keyPairs...)
sessionStore.MaxAge(int(conf.HTTP.Session.Cookie.MaxAge))
sessionStore.Options.Path = conf.HTTP.Session.Cookie.Path
sessionStore.Options.HttpOnly = conf.HTTP.Session.Cookie.HTTPOnly
sessionStore.Options.Secure = conf.HTTP.Session.Cookie.Secure
// Configure providers
gothProviders := make([]goth.Provider, 0)
providers := make([]auth.Provider, 0)
if conf.Auth.Providers.Google.Key != "" && conf.Auth.Providers.Google.Secret != "" {
googleProvider := google.New(
conf.Auth.Providers.Google.Key,
conf.Auth.Providers.Google.Secret,
fmt.Sprintf("%s/auth/providers/google/callback", conf.HTTP.BaseURL),
conf.Auth.Providers.Google.Scopes...,
)
gothProviders = append(gothProviders, googleProvider)
providers = append(providers, auth.Provider{
ID: googleProvider.Name(),
Label: "Google",
Icon: "fa-google",
})
}
if conf.Auth.Providers.Github.Key != "" && conf.Auth.Providers.Github.Secret != "" {
githubProvider := github.New(
conf.Auth.Providers.Github.Key,
conf.Auth.Providers.Github.Secret,
fmt.Sprintf("%s/auth/providers/github/callback", conf.HTTP.BaseURL),
conf.Auth.Providers.Github.Scopes...,
)
gothProviders = append(gothProviders, githubProvider)
providers = append(providers, auth.Provider{
ID: githubProvider.Name(),
Label: "Github",
Icon: "fa-github",
})
}
if conf.Auth.Providers.OIDC.Key != "" && conf.Auth.Providers.OIDC.Secret != "" {
oidcProvider, err := openidConnect.New(
conf.Auth.Providers.OIDC.Key,
conf.Auth.Providers.OIDC.Secret,
fmt.Sprintf("%s/auth/providers/openid-connect/callback", conf.HTTP.BaseURL),
conf.Auth.Providers.OIDC.DiscoveryURL,
conf.Auth.Providers.OIDC.Scopes...,
)
if err != nil {
return nil, errors.Wrap(err, "could not configure oidc provider")
}
gothProviders = append(gothProviders, oidcProvider)
providers = append(providers, auth.Provider{
ID: oidcProvider.Name(),
Label: conf.Auth.Providers.OIDC.Label,
Icon: conf.Auth.Providers.OIDC.Icon,
})
}
goth.UseProviders(gothProviders...)
gothic.Store = sessionStore
opts := []auth.OptionFunc{
auth.WithProviders(providers...),
}
if conf.Auth.DefaultAdmin.Email != "" && conf.Auth.DefaultAdmin.Provider != "" {
opts = append(opts, auth.WithDefaultAdmin(auth.DefaultAdmin{
Provider: conf.Auth.DefaultAdmin.Provider,
Email: conf.Auth.DefaultAdmin.Email,
}))
}
auth := auth.NewHandler(
sessionStore,
opts...,
)
return auth, nil
}
func getRandomBytes(n int) ([]byte, error) {
data := make([]byte, n)
read, err := rand.Read(data)
if err != nil {
return nil, errors.WithStack(err)
}
if read != n {
return nil, errors.Errorf("could not read %d bytes", n)
}
return data, nil
}

View File

@ -0,0 +1,27 @@
package setup
import (
"context"
"forge.cadoles.com/wpetit/clearcase/internal/config"
"forge.cadoles.com/wpetit/clearcase/internal/http"
"github.com/pkg/errors"
)
func NewHTTPServerFromConfig(ctx context.Context, conf *config.Config) (*http.Server, error) {
// Configure Web UI handler
webui, err := NewWebUIHandlerFromConfig(ctx, conf)
if err != nil {
return nil, errors.Wrap(err, "could not configure webui handler from config")
}
// Create HTTP server
server := http.NewServer(
webui,
http.WithAddress(conf.HTTP.Address),
http.WithBaseURL(conf.HTTP.BaseURL),
)
return server, nil
}

View File

@ -0,0 +1,12 @@
package setup
import (
"context"
"forge.cadoles.com/wpetit/clearcase/internal/config"
"forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/issue"
)
func NewIssueHandlerFromConfig(ctx context.Context, conf *config.Config) (*issue.Handler, error) {
return issue.NewHandler(), nil
}

View File

@ -0,0 +1,44 @@
package setup
import (
"net/url"
"github.com/pkg/errors"
)
var ErrNotRegistered = errors.New("not registered")
type Factory[T any] func(u *url.URL) (T, error)
type Registry[T any] struct {
mappings map[string]Factory[T]
}
func (r *Registry[T]) Register(scheme string, factory Factory[T]) {
r.mappings[scheme] = factory
}
func (r *Registry[T]) From(rawURL string) (T, error) {
u, err := url.Parse(rawURL)
if err != nil {
return *new(T), errors.WithStack(err)
}
factory, exists := r.mappings[u.Scheme]
if !exists {
return *new(T), errors.Wrapf(ErrNotRegistered, "scheme '%s' not found", u.Scheme)
}
value, err := factory(u)
if err != nil {
return *new(T), errors.WithStack(err)
}
return value, nil
}
func NewRegistry[T any]() *Registry[T] {
return &Registry[T]{
mappings: make(map[string]Factory[T]),
}
}

View File

@ -0,0 +1,48 @@
package setup
import (
"context"
"forge.cadoles.com/wpetit/clearcase/internal/config"
"forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui"
"forge.cadoles.com/wpetit/clearcase/internal/http/handler/webui/common"
"github.com/pkg/errors"
)
func NewWebUIHandlerFromConfig(ctx context.Context, conf *config.Config) (*webui.Handler, error) {
opts := make([]webui.OptionFunc, 0)
// Configure auth handler
authHandler, err := NewAuthHandlerFromConfig(ctx, conf)
if err != nil {
return nil, errors.Wrap(err, "could not configure auth handler from config")
}
authMiddleware := authHandler.Middleware()
opts = append(opts, webui.WithMount("/auth/", authHandler))
// Configure issue handler
issueHandler, err := NewIssueHandlerFromConfig(ctx, conf)
if err != nil {
return nil, errors.Wrap(err, "could not configure explorer handler from config")
}
opts = append(opts, webui.WithMount("/", authMiddleware(issueHandler)))
// Configure common handler
commonHandler, err := NewCommonHandlerFromConfig(ctx, conf)
if err != nil {
return nil, errors.Wrap(err, "could not configure common handler from config")
}
opts = append(opts, webui.WithMount("/assets/", commonHandler))
handler := webui.NewHandler(opts...)
return handler, nil
}
func NewCommonHandlerFromConfig(ctx context.Context, conf *config.Config) (*common.Handler, error) {
return common.NewHandler(), nil
}

40
misc/docker/Dockerfile Normal file
View File

@ -0,0 +1,40 @@
FROM golang:1.23 AS build
RUN apt-get update \
&& apt-get install -y make git
RUN --mount=type=cache,target=/go/pkg/mod/ \
--mount=type=bind,source=go.sum,target=go.sum \
--mount=type=bind,source=go.mod,target=go.mod \
go mod download -x
WORKDIR /src
COPY . /src
ARG GOCACHE=/root/.cache/go-build
RUN --mount=type=cache,target=/go/pkg/mod/ \
--mount=type=cache,target="/root/.cache/go-build" \
--mount=type=cache,target=./tools \
make build
FROM alpine:3.21 AS runtime
RUN apk add \
ca-certificates \
openssl \
openjdk21-jdk \
msttcorefonts-installer \
ttf-dejavu \
fontconfig \
tesseract-ocr \
tesseract-ocr-data-eng \
tesseract-ocr-data-fra \
&& update-ms-fonts \
&& fc-cache -f -v \
&& update-ca-certificates
COPY --from=build /src/bin/clearcase /usr/local/bin/clearcase
CMD ["/usr/local/bin/clearcase"]

8
misc/docker/docker.mk Normal file
View File

@ -0,0 +1,8 @@
DOCKER_STANDALONE_IMAGE_NAME := clearcase-standalone
DOCKER_STANDALONE_IMAGE_TAG := latest
docker-image:
docker build -f misc/docker/Dockerfile -t $(DOCKER_STANDALONE_IMAGE_NAME):$(DOCKER_STANDALONE_IMAGE_TAG) .
docker-run:
docker run -it --rm -p 3000:3000 --name rkvst --tmpfs /tmp --env-file .env $(DOCKER_STANDALONE_IMAGE_NAME):$(DOCKER_STANDALONE_IMAGE_TAG)

11
modd.conf Normal file
View File

@ -0,0 +1,11 @@
internal/**/*.go
.env
Makefile
modd.conf {
prep: "make -o generate build"
daemon: "make CMD='bin/rkvst' run-with-env"
}
internal/**/*.templ {
prep: make build
}