Compare commits
134 Commits
refactor/l
...
feat/user-
| Author | SHA1 | Date | |
|---|---|---|---|
|
41a764939b
|
|||
|
dc61ed91d0
|
|||
|
3f767299e2
|
|||
|
d5f13b563e
|
|||
|
7308097c61
|
|||
|
e3ce651288
|
|||
|
0e7198f918
|
|||
|
f4a971bdae
|
|||
|
8403459cc8
|
|||
|
4e90fa0063
|
|||
|
f622f774e4
|
|||
|
29f1e8cc8a
|
|||
|
0cea250fa4
|
|||
|
c3a0be5ec5
|
|||
|
72b884c2b3
|
|||
|
d38097a616
|
|||
|
f7396dc12b
|
|||
|
149823a5fc
|
|||
|
56e2214311
|
|||
|
a52caf6580
|
|||
|
30eb1a0065
|
|||
|
106c612e63
|
|||
|
06807b0623
|
|||
|
12844eafee
|
|||
|
d5668af2df
|
|||
|
4bb32f9757
|
|||
|
52ac9ed3bc
|
|||
|
2bce92e51c
|
|||
|
28ed7379de
|
|||
|
16322b3afd
|
|||
|
5fbe9cd1ad
|
|||
|
f7f2a7fbb8
|
|||
|
b29bfdd1df
|
|||
|
deaf9089b2
|
|||
|
ffad82b32c
|
|||
|
dbf30a9908
|
|||
|
acda6dbd24
|
|||
|
a4fc9176cd
|
|||
|
9ecacc3808
|
|||
|
7f6f9f7682
|
|||
|
884133941f
|
|||
|
c05445f702
|
|||
|
1466c35e39
|
|||
|
1ade2d8f63
|
|||
|
eb72bab886
|
|||
|
294513a772
|
|||
|
87e7a74dd3
|
|||
|
f7704b4f18
|
|||
|
b1f6bde29f
|
|||
|
fbe01ad098
|
|||
|
50b387ccf2
|
|||
|
f1912240a0
|
|||
|
b9cb8948fc
|
|||
|
5dc04d29d9
|
|||
|
70b6491565
|
|||
|
b329d8cfba
|
|||
|
2862824b7b
|
|||
|
0bfc828caf
|
|||
|
3524eb2944
|
|||
|
cdcc410089
|
|||
|
757ed62edd
|
|||
|
5c873a2707
|
|||
|
8af80c702f
|
|||
|
7e78726bcb
|
|||
|
bd5132354f
|
|||
|
788fdfd9e3
|
|||
|
e4d53084a6
|
|||
|
01eb5d90e0
|
|||
|
268e0a9d8b
|
|||
|
f13313da30
|
|||
|
07460aaaca
|
|||
|
845d4b40c3
|
|||
|
b93ff0512f
|
|||
|
7c1246adb4
|
|||
|
329b2ca953
|
|||
|
8273ff6a1d
|
|||
|
82f2c7e67a
|
|||
|
69a291d19e
|
|||
|
fa66837cdd
|
|||
|
47c3de3c8f
|
|||
|
e40896c53f
|
|||
|
bfe7a01aa5
|
|||
|
7c28a53965
|
|||
|
dac00296b7
|
|||
|
9579d83661
|
|||
|
4ae94cfe7d
|
|||
|
de99160688
|
|||
|
c6d99690ed
|
|||
|
99a76dcad3
|
|||
|
a2ca597578
|
|||
|
9e5a15963e
|
|||
|
2d9b3e29d6
|
|||
|
f45aff6d6f
|
|||
|
e121bbde87
|
|||
|
8708a29a21
|
|||
|
94e6396a6c
|
|||
|
ab61af503e
|
|||
|
8fbb9e1671
|
|||
|
ae10dfa7ca
|
|||
|
ea8ca4284b
|
|||
|
71cd17bb97
|
|||
|
9fbcbb96c0
|
|||
|
4aeeb8479b
|
|||
|
6eb4825d1c
|
|||
|
c8285833d4
|
|||
|
4ee46e2dc8
|
|||
|
3d346ca5fe
|
|||
|
fca5ad29b9
|
|||
|
1c608b30be
|
|||
|
789512f6e1
|
|||
|
feaa21b827
|
|||
|
32329e1e17
|
|||
|
2187848712
|
|||
|
eb53285f03
|
|||
|
98c389cb0c
|
|||
|
800412d315
|
|||
|
6e2664756b
|
|||
|
bc658d7dc8
|
|||
|
5f88be7244
|
|||
|
4be737b292
|
|||
|
b0c6d70406
|
|||
|
fc757d36f0
|
|||
|
0ae642f17b
|
|||
|
145da05708
|
|||
|
8cb5ca3917
|
|||
|
ee93e78b28
|
|||
|
f9e9d95c80
|
|||
|
742287e522
|
|||
|
6ddba55413
|
|||
|
7de9126ea1
|
|||
|
dc33adb733
|
|||
|
6146819503
|
|||
|
ac4c681b7c
|
|||
|
c25e8b0f1d
|
5
.EXAMPLE.env
Normal file
5
.EXAMPLE.env
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
AWS_ACCESS_KEY_ID=**************************
|
||||||
|
AWS_SECRET_ACCESS_KEY=****************************************************************
|
||||||
|
AWS_DEFAULT_REGION=******
|
||||||
|
AWS_ENDPOINT_URL=http://localhost:3900
|
||||||
|
DATABASE_URL=file:./libsql.db
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
.dist
|
.dist
|
||||||
wind.css
|
out.css
|
||||||
.tmp
|
.tmp
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
tmp
|
||||||
|
|||||||
6
.vscode/launch.json
vendored
6
.vscode/launch.json
vendored
@@ -6,15 +6,15 @@
|
|||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "debug",
|
"mode": "debug",
|
||||||
"program": "${workspaceFolder}/main.go"
|
"program": "${workspaceFolder}/cmd/cmd.go"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Launch APP (Dev)",
|
"name": "Launch APP (Dev)",
|
||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "debug",
|
"mode": "debug",
|
||||||
"program": "${workspaceFolder}/main.go",
|
"program": "${workspaceFolder}/cmd/cmd.go",
|
||||||
"args": [ "-dev" ]
|
"args": ["-dev", "-port", "8080", "-hostname", "0.0.0.0"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
22
assets/assets.go
Normal file
22
assets/assets.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package assets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed stylesheets/out.css
|
||||||
|
var files embed.FS
|
||||||
|
|
||||||
|
func Files(local ...bool) fs.FS {
|
||||||
|
var l bool
|
||||||
|
if len(local) > 0 {
|
||||||
|
l = local[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if !l {
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
1
assets/stylesheets/tailwind.css
Normal file
1
assets/stylesheets/tailwind.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
117
cmd/cmd.go
117
cmd/cmd.go
@@ -1,39 +1,72 @@
|
|||||||
package cmd
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"forge.capytal.company/capytalcode/project-comicverse/router"
|
comicverse "forge.capytal.company/capytalcode/project-comicverse"
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/templates"
|
||||||
"forge.capytal.company/loreddev/x/tinyssert"
|
"forge.capytal.company/loreddev/x/tinyssert"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
_ "github.com/tursodatabase/go-libsql"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
host = flag.String("host", "localhost", "Host to listen to")
|
hostname = flag.String("hostname", "localhost", "Host to listen to")
|
||||||
port = flag.Uint("port", 8080, "Port to be used for the server.")
|
port = flag.Uint("port", 8080, "Port to be used for the server.")
|
||||||
templatesDir = flag.String("templates", "", "Templates directory to be used instead of built-in ones.")
|
templatesDir = flag.String("templates", "", "Templates directory to be used instead of built-in ones.")
|
||||||
verbose = flag.Bool("verbose", false, "Print debug information on logs")
|
verbose = flag.Bool("verbose", false, "Print debug information on logs")
|
||||||
dev = flag.Bool("dev", false, "Run the server in debug mode.")
|
dev = flag.Bool("dev", false, "Run the server in debug mode.")
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
var (
|
||||||
flag.Parse()
|
databaseURL = getEnv("DATABASE_URL", "file:./libsql.db")
|
||||||
|
|
||||||
|
awsAccessKeyID = os.Getenv("AWS_ACCESS_KEY_ID")
|
||||||
|
awsSecretAccessKey = os.Getenv("AWS_SECRET_ACCESS_KEY")
|
||||||
|
awsDefaultRegion = os.Getenv("AWS_DEFAULT_REGION")
|
||||||
|
awsEndpointURL = os.Getenv("AWS_ENDPOINT_URL")
|
||||||
|
s3Bucket = os.Getenv("S3_BUCKET")
|
||||||
|
)
|
||||||
|
|
||||||
|
func getEnv(key string, d string) string {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func Execute() {
|
func init() {
|
||||||
ctx := context.Background()
|
flag.Parse()
|
||||||
|
|
||||||
assertions := tinyssert.NewDisabledAssertions()
|
switch {
|
||||||
if *dev {
|
case databaseURL == "":
|
||||||
assertions = tinyssert.NewAssertions()
|
log.Fatal("DATABASE_URL should not be a empty value")
|
||||||
|
case awsAccessKeyID == "":
|
||||||
|
log.Fatal("AWS_ACCESS_KEY_ID should not be a empty value")
|
||||||
|
case awsDefaultRegion == "":
|
||||||
|
log.Fatal("AWS_DEFAULT_REGION should not be a empty value")
|
||||||
|
case awsEndpointURL == "":
|
||||||
|
log.Fatal("AWS_ENDPOINT_URL should not be a empty value")
|
||||||
|
case s3Bucket == "":
|
||||||
|
log.Fatal("S3_BUCKET should not be a empty value")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
level := slog.LevelError
|
level := slog.LevelError
|
||||||
if *dev {
|
if *dev {
|
||||||
@@ -43,10 +76,62 @@ func Execute() {
|
|||||||
}
|
}
|
||||||
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||||
|
|
||||||
app := router.New(assertions, log, *dev)
|
assertions := tinyssert.NewDisabled()
|
||||||
|
if *dev {
|
||||||
|
assertions = tinyssert.New(
|
||||||
|
tinyssert.WithPanic(),
|
||||||
|
tinyssert.WithLogger(log),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("libsql", databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed open connection to database", slog.String("error", err.Error()))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials := aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) {
|
||||||
|
return aws.Credentials{
|
||||||
|
AccessKeyID: awsAccessKeyID,
|
||||||
|
SecretAccessKey: awsSecretAccessKey,
|
||||||
|
CanExpire: false,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
storage := s3.New(s3.Options{
|
||||||
|
AppID: "comicverse-pre-alpha",
|
||||||
|
BaseEndpoint: &awsEndpointURL,
|
||||||
|
Region: awsDefaultRegion,
|
||||||
|
Credentials: &credentials,
|
||||||
|
})
|
||||||
|
|
||||||
|
opts := []comicverse.Option{
|
||||||
|
comicverse.WithContext(ctx),
|
||||||
|
comicverse.WithAssertions(assertions),
|
||||||
|
comicverse.WithLogger(log),
|
||||||
|
}
|
||||||
|
|
||||||
|
if *dev {
|
||||||
|
d := os.DirFS("./assets")
|
||||||
|
opts = append(opts, comicverse.WithAssets(d))
|
||||||
|
|
||||||
|
t := templates.NewHotTemplates(os.DirFS("./templates"))
|
||||||
|
opts = append(opts, comicverse.WithTemplates(t))
|
||||||
|
|
||||||
|
opts = append(opts, comicverse.WithDevelopmentMode())
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := comicverse.New(comicverse.Config{
|
||||||
|
DB: db,
|
||||||
|
S3: storage,
|
||||||
|
Bucket: s3Bucket,
|
||||||
|
}, opts...)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to initiate comicverse app", slog.String("error", err.Error()))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%d", *host, *port),
|
Addr: fmt.Sprintf("%s:%d", *hostname, *port),
|
||||||
Handler: app,
|
Handler: app,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,13 +140,14 @@ func Execute() {
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
log.Info("Starting application",
|
log.Info("Starting application",
|
||||||
slog.String("host", *host),
|
slog.String("host", *hostname),
|
||||||
slog.Uint64("port", uint64(*port)),
|
slog.Uint64("port", uint64(*port)),
|
||||||
slog.Bool("verbose", *verbose),
|
slog.Bool("verbose", *verbose),
|
||||||
slog.Bool("development", *dev))
|
slog.Bool("development", *dev))
|
||||||
|
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
log.Error("Failed to start application", slog.String("error", err.Error()))
|
log.Error("Failed to start application server", slog.String("error", err.Error()))
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -69,7 +155,8 @@ func Execute() {
|
|||||||
|
|
||||||
log.Info("Stopping application gracefully")
|
log.Info("Stopping application gracefully")
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
log.Error("Failed to stop application gracefully", slog.String("error", err.Error()))
|
log.Error("Failed to stop application server gracefully", slog.String("error", err.Error()))
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("FINAL")
|
log.Info("FINAL")
|
||||||
|
|||||||
160
comicverse.go
Normal file
160
comicverse.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package comicverse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/assets"
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/internals/joinedfs"
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/repository"
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/router"
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/service"
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/templates"
|
||||||
|
"forge.capytal.company/loreddev/x/tinyssert"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(cfg Config, opts ...Option) (http.Handler, error) {
|
||||||
|
app := &app{
|
||||||
|
db: cfg.DB,
|
||||||
|
s3: cfg.S3,
|
||||||
|
bucket: cfg.Bucket,
|
||||||
|
|
||||||
|
assets: assets.Files(),
|
||||||
|
templates: templates.Templates(),
|
||||||
|
developmentMode: false,
|
||||||
|
ctx: context.Background(),
|
||||||
|
|
||||||
|
assert: tinyssert.New(),
|
||||||
|
logger: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.db == nil {
|
||||||
|
return nil, errors.New("database interface must not be nil")
|
||||||
|
}
|
||||||
|
if app.s3 == nil {
|
||||||
|
return nil, errors.New("s3 client must not be nil")
|
||||||
|
}
|
||||||
|
if app.bucket == "" {
|
||||||
|
return nil, errors.New("bucket must not be a empty string")
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.assets == nil {
|
||||||
|
return nil, errors.New("static files must not be a nil interface")
|
||||||
|
}
|
||||||
|
if app.templates == nil {
|
||||||
|
return nil, errors.New("templates must not be a nil interface")
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.ctx == nil {
|
||||||
|
return nil, errors.New("context must not be a nil interface")
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.logger == nil {
|
||||||
|
return nil, errors.New("logger must not be a nil interface")
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.assert == nil {
|
||||||
|
return nil, errors.New("assertions must not be a nil interface")
|
||||||
|
}
|
||||||
|
|
||||||
|
return app, app.setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DB *sql.DB
|
||||||
|
S3 *s3.Client
|
||||||
|
Bucket string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(*app)
|
||||||
|
|
||||||
|
func WithContext(ctx context.Context) Option {
|
||||||
|
return func(app *app) { app.ctx = ctx }
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAssets(f fs.FS) Option {
|
||||||
|
return func(app *app) { app.assets = joinedfs.Join(f, app.assets) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithTemplates(t templates.ITemplate) Option {
|
||||||
|
return func(app *app) { app.templates = t }
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAssertions(a tinyssert.Assertions) Option {
|
||||||
|
return func(app *app) { app.assert = a }
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithLogger(l *slog.Logger) Option {
|
||||||
|
return func(app *app) { app.logger = l }
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithDevelopmentMode() Option {
|
||||||
|
return func(app *app) { app.developmentMode = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
type app struct {
|
||||||
|
db *sql.DB
|
||||||
|
s3 *s3.Client
|
||||||
|
bucket string
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
|
||||||
|
assets fs.FS
|
||||||
|
templates templates.ITemplate
|
||||||
|
developmentMode bool
|
||||||
|
|
||||||
|
handler http.Handler
|
||||||
|
|
||||||
|
assert tinyssert.Assertions
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *app) setup() error {
|
||||||
|
app.assert.NotNil(app.db)
|
||||||
|
app.assert.NotNil(app.s3)
|
||||||
|
app.assert.NotZero(app.bucket)
|
||||||
|
app.assert.NotNil(app.ctx)
|
||||||
|
app.assert.NotNil(app.assets)
|
||||||
|
app.assert.NotNil(app.logger)
|
||||||
|
|
||||||
|
userRepo, err := repository.NewUserRepository(app.db, app.ctx, app.logger, app.assert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
userService, err := service.NewUserService(userRepo, app.assert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
app.handler, err = router.New(router.Config{
|
||||||
|
UserService: userService,
|
||||||
|
|
||||||
|
Templates: app.templates,
|
||||||
|
DisableCache: app.developmentMode,
|
||||||
|
Assets: app.assets,
|
||||||
|
|
||||||
|
Assertions: app.assert,
|
||||||
|
Logger: app.logger.WithGroup("router"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return errors.Join(errors.New("unable to initiate router"), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *app) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
app.assert.NotNil(app.handler)
|
||||||
|
app.handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1726243404,
|
"lastModified": 1742069588,
|
||||||
"narHash": "sha256-sjiGsMh+1cWXb53Tecsm4skyFNag33GPbVgCdfj3n9I=",
|
"narHash": "sha256-C7jVfohcGzdZRF6DO+ybyG/sqpo1h6bZi9T56sxLy+k=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "345c263f2f53a3710abe117f28a5cb86d0ba4059",
|
"rev": "c80f6a7e10b39afcc1894e02ef785b1ad0b0d7e5",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
16
flake.nix
16
flake.nix
@@ -18,8 +18,14 @@
|
|||||||
in {
|
in {
|
||||||
devShells = forAllSystems (system: pkgs: {
|
devShells = forAllSystems (system: pkgs: {
|
||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
CGO_ENABLED = "0";
|
CGO_ENABLED = "1";
|
||||||
hardeningDisable = ["all"];
|
hardeningDisable = ["fortify"];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
'';
|
||||||
|
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
# Go tools
|
# Go tools
|
||||||
@@ -30,11 +36,15 @@
|
|||||||
delve
|
delve
|
||||||
|
|
||||||
# TailwindCSS
|
# TailwindCSS
|
||||||
tailwindcss
|
tailwindcss_4
|
||||||
|
|
||||||
# Sqlite tools
|
# Sqlite tools
|
||||||
sqlite
|
sqlite
|
||||||
lazysql
|
lazysql
|
||||||
|
litecli
|
||||||
|
|
||||||
|
# S3
|
||||||
|
awscli
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
27
go.mod
27
go.mod
@@ -1,7 +1,28 @@
|
|||||||
module forge.capytal.company/capytalcode/project-comicverse
|
module forge.capytal.company/capytalcode/project-comicverse
|
||||||
|
|
||||||
go 1.23.3
|
go 1.24.1
|
||||||
|
|
||||||
toolchain go1.23.6
|
require (
|
||||||
|
forge.capytal.company/loreddev/x v0.0.0-20250305165122-0ccb26ab783b
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.36.3
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92
|
||||||
|
golang.org/x/crypto v0.38.0
|
||||||
|
)
|
||||||
|
|
||||||
require forge.capytal.company/loreddev/x v0.0.0-20250227192157-90a5169f1bef
|
require (
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
|
||||||
|
github.com/aws/smithy-go v1.22.2 // indirect
|
||||||
|
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
|
||||||
|
)
|
||||||
|
|||||||
48
go.sum
48
go.sum
@@ -1,2 +1,46 @@
|
|||||||
forge.capytal.company/loreddev/x v0.0.0-20250227192157-90a5169f1bef h1:IJ9z7otITB5hhjZ+bmU0yOVsa8K1RWYIZ+cQj9XF6NY=
|
forge.capytal.company/loreddev/x v0.0.0-20250305165122-0ccb26ab783b h1:QxTrkGp1cBiPs5vd1Lkh+I/3kNc82CQ5VkF3Cp+8R3E=
|
||||||
forge.capytal.company/loreddev/x v0.0.0-20250227192157-90a5169f1bef/go.mod h1:MnU08vmXvYIQlQutVcC6o6Xq1KHZuXGXO78bbHseCFo=
|
forge.capytal.company/loreddev/x v0.0.0-20250305165122-0ccb26ab783b/go.mod h1:Fc5nkrgOwJYdiwZK9SElFAB5xd7C/fh/mD+tBERfUPM=
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 h1:t/gZFyrijKuSU0elA5kRngP/oU3mc0I+Dvp8HwRE4c0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1 h1:1M0gSbyP6q06gl3384wpoKPaH9G16NPqZFieEhLboSU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1/go.mod h1:4qzsZSzB/KiX2EzDjs9D7A8rI/WGJxZceVJIHqtJjIU=
|
||||||
|
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
|
||||||
|
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
|
github.com/google/go-cmp v0.5.9/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/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
|
||||||
|
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92 h1:IYI1S1xt4WdQHjgVYzMa+Owot82BqlZfQV05BLnTcTA=
|
||||||
|
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
|
||||||
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
|
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
|
||||||
|
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||||
|
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||||
|
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||||
|
|||||||
25
internals/joinedfs/joinedfs.go
Normal file
25
internals/joinedfs/joinedfs.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package joinedfs
|
||||||
|
|
||||||
|
import "io/fs"
|
||||||
|
|
||||||
|
func Join(fsys ...fs.FS) fs.FS {
|
||||||
|
return &joinedFS{fsys}
|
||||||
|
}
|
||||||
|
|
||||||
|
type joinedFS struct {
|
||||||
|
fsys []fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ fs.FS = (*joinedFS)(nil)
|
||||||
|
|
||||||
|
func (j *joinedFS) Open(name string) (fs.File, error) {
|
||||||
|
var err error
|
||||||
|
var f fs.File
|
||||||
|
for _, fsys := range j.fsys {
|
||||||
|
f, err = fsys.Open(name)
|
||||||
|
if err == nil {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f, err
|
||||||
|
}
|
||||||
75
internals/randstr/randstr.go
Normal file
75
internals/randstr/randstr.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// This file has code copied from the "randstr" Go module, which can be found at
|
||||||
|
// https://github.com/thanhpk/randsr. The original code is licensed under the MIT
|
||||||
|
// license, which a copy can be found at https://github.com/thanhpk/randstr/blob/master/LICENSE
|
||||||
|
// and is provided below:
|
||||||
|
//
|
||||||
|
// # The MIT License
|
||||||
|
//
|
||||||
|
// Copyright (c) 2010-2018 Google, Inc. http://angularjs.org
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
// Package randstr provides basic functions for generating random bytes, string
|
||||||
|
package randstr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HexChars holds a string containing all characters used in a hexadecimal value.
|
||||||
|
const HexChars = "0123456789abcdef"
|
||||||
|
|
||||||
|
// NewHex generates a new Hexadecimal string with length of n
|
||||||
|
//
|
||||||
|
// Example: 67aab2d956bd7cc621af22cfb169cba8
|
||||||
|
func NewHex(n int) (string, error) { return New(n, HexChars) }
|
||||||
|
|
||||||
|
// New generates a random string using only letters provided in the letters parameter.
|
||||||
|
//
|
||||||
|
// If the letters parameter is omitted, this function will use HexChars instead.
|
||||||
|
func New(n int, chars ...string) (string, error) {
|
||||||
|
runes := []rune(HexChars)
|
||||||
|
if len(chars) > 0 {
|
||||||
|
runes = []rune(chars[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.Grow(n)
|
||||||
|
l := uint32(len(runes))
|
||||||
|
for range n {
|
||||||
|
by, err := Bytes(4)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b.WriteRune(runes[binary.BigEndian.Uint32(by)%l])
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes generates n random bytes
|
||||||
|
func Bytes(n int) ([]byte, error) {
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
return []byte{}, err
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
195
ipub/ast/ast.go
Normal file
195
ipub/ast/ast.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package ast
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Node interface {
|
||||||
|
Kind() NodeKind
|
||||||
|
|
||||||
|
NextSibling() Node
|
||||||
|
SetNextSibling(Node)
|
||||||
|
|
||||||
|
PreviousSibling() Node
|
||||||
|
SetPreviousSibling(Node)
|
||||||
|
|
||||||
|
Parent() Node
|
||||||
|
SetParent(Node)
|
||||||
|
|
||||||
|
HasChildren() bool
|
||||||
|
ChildCount() uint
|
||||||
|
|
||||||
|
FirstChild() Node
|
||||||
|
LastChild() Node
|
||||||
|
|
||||||
|
AppendChild(self, v Node)
|
||||||
|
RemoveChild(self, v Node)
|
||||||
|
|
||||||
|
RemoveChildren(self Node)
|
||||||
|
ReplaceChild(self, v1, insertee Node)
|
||||||
|
|
||||||
|
InsertBefore(self, v1, insertee Node)
|
||||||
|
InsertAfter(self, v1, insertee Node)
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseNode struct {
|
||||||
|
next Node
|
||||||
|
prev Node
|
||||||
|
parent Node
|
||||||
|
fisrtChild Node
|
||||||
|
lastChild Node
|
||||||
|
childCount uint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) NextSibling() Node {
|
||||||
|
return e.next
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) SetNextSibling(v Node) {
|
||||||
|
e.next = v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) PreviousSibling() Node {
|
||||||
|
return e.prev
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) SetPreviousSibling(v Node) {
|
||||||
|
e.prev = v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) Parent() Node {
|
||||||
|
return e.parent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) SetParent(v Node) {
|
||||||
|
e.parent = v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) HasChildren() bool {
|
||||||
|
return e.fisrtChild != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) ChildCount() uint {
|
||||||
|
return e.childCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) FirstChild() Node {
|
||||||
|
return e.fisrtChild
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) LastChild() Node {
|
||||||
|
return e.lastChild
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) AppendChild(self, v Node) {
|
||||||
|
ensureIsolated(v)
|
||||||
|
|
||||||
|
if e.fisrtChild == nil {
|
||||||
|
e.fisrtChild = v
|
||||||
|
v.SetNextSibling(nil)
|
||||||
|
v.SetPreviousSibling(nil)
|
||||||
|
} else {
|
||||||
|
l := e.lastChild
|
||||||
|
l.SetNextSibling(v)
|
||||||
|
v.SetPreviousSibling(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
v.SetParent(self)
|
||||||
|
e.lastChild = v
|
||||||
|
e.childCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) RemoveChild(self, v Node) {
|
||||||
|
if v.Parent() != self {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.childCount <= 0 {
|
||||||
|
e.childCount--
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := v.PreviousSibling()
|
||||||
|
next := v.NextSibling()
|
||||||
|
|
||||||
|
if prev != nil {
|
||||||
|
prev.SetNextSibling(next)
|
||||||
|
} else {
|
||||||
|
e.fisrtChild = next
|
||||||
|
}
|
||||||
|
|
||||||
|
if next != nil {
|
||||||
|
next.SetNextSibling(prev)
|
||||||
|
} else {
|
||||||
|
e.lastChild = prev
|
||||||
|
}
|
||||||
|
|
||||||
|
v.SetParent(nil)
|
||||||
|
v.SetNextSibling(nil)
|
||||||
|
v.SetPreviousSibling(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) RemoveChildren(_ Node) {
|
||||||
|
for c := e.fisrtChild; c != nil; {
|
||||||
|
c.SetParent(nil)
|
||||||
|
c.SetPreviousSibling(nil)
|
||||||
|
next := c.NextSibling()
|
||||||
|
c.SetNextSibling(nil)
|
||||||
|
c = next
|
||||||
|
}
|
||||||
|
e.fisrtChild = nil
|
||||||
|
e.lastChild = nil
|
||||||
|
e.childCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) ReplaceChild(self, v1, insertee Node) {
|
||||||
|
e.InsertBefore(self, v1, insertee)
|
||||||
|
e.RemoveChild(self, v1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) InsertAfter(self, v1, insertee Node) {
|
||||||
|
e.InsertBefore(self, v1.NextSibling(), insertee)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BaseNode) InsertBefore(self, v1, insertee Node) {
|
||||||
|
e.childCount++
|
||||||
|
if v1 == nil {
|
||||||
|
e.AppendChild(self, insertee)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureIsolated(insertee)
|
||||||
|
|
||||||
|
if v1.Parent() == self {
|
||||||
|
c := v1
|
||||||
|
prev := c.PreviousSibling()
|
||||||
|
if prev != nil {
|
||||||
|
prev.SetNextSibling(insertee)
|
||||||
|
insertee.SetPreviousSibling(prev)
|
||||||
|
} else {
|
||||||
|
e.fisrtChild = insertee
|
||||||
|
insertee.SetPreviousSibling(nil)
|
||||||
|
}
|
||||||
|
insertee.SetNextSibling(c)
|
||||||
|
c.SetPreviousSibling(insertee)
|
||||||
|
insertee.SetParent(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureIsolated(e Node) {
|
||||||
|
if p := e.Parent(); p != nil {
|
||||||
|
p.RemoveChild(p, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeKind string
|
||||||
|
|
||||||
|
func NewNodeKind(kind string, e Node) NodeKind {
|
||||||
|
k := NodeKind(kind)
|
||||||
|
if _, ok := elementKindList[k]; ok {
|
||||||
|
panic(fmt.Sprintf("Node kind %q is already registered", k))
|
||||||
|
}
|
||||||
|
elementKindList[k] = e
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
var elementKindList = make(map[NodeKind]Node)
|
||||||
77
ipub/ast/ast_test.go
Normal file
77
ipub/ast/ast_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package ast_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/xml"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/ipub/ast"
|
||||||
|
"forge.capytal.company/loreddev/x/tinyssert"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed test.xml
|
||||||
|
var test []byte
|
||||||
|
|
||||||
|
func TestMarshal(t *testing.T) {
|
||||||
|
b := &ast.Body{}
|
||||||
|
c := &ast.Content{}
|
||||||
|
i := &ast.Image{}
|
||||||
|
i.SetSource("https://hello.com/world.png")
|
||||||
|
c.AppendChild(c, i)
|
||||||
|
b.AppendChild(b, c)
|
||||||
|
|
||||||
|
s := ast.Section{
|
||||||
|
Body: b,
|
||||||
|
}
|
||||||
|
by, err := xml.Marshal(s)
|
||||||
|
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
t.Error(err.Error())
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// t.Logf("%#v", s.Body)
|
||||||
|
//
|
||||||
|
// t.Logf("%#v", f)
|
||||||
|
|
||||||
|
t.Logf("%#v", string(by))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshal(t *testing.T) {
|
||||||
|
assert := tinyssert.New(tinyssert.WithTest(t), tinyssert.WithPanic())
|
||||||
|
|
||||||
|
s := []byte(`
|
||||||
|
<html>
|
||||||
|
<body data-ipub-element="body">
|
||||||
|
<section data-ipub-element="content">
|
||||||
|
<img data-ipub-element="image" src="https://hello.com/world.png"/>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`)
|
||||||
|
|
||||||
|
var data ast.Section
|
||||||
|
|
||||||
|
err := xml.Unmarshal(s, &data)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
t.Error(err.Error())
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
body := data.Body
|
||||||
|
assert.Equal(ast.KindBody, body.Kind())
|
||||||
|
|
||||||
|
t.Logf("%#v", body)
|
||||||
|
|
||||||
|
content := body.FirstChild()
|
||||||
|
assert.Equal(ast.KindContent, content.Kind())
|
||||||
|
|
||||||
|
t.Logf("%#v", content)
|
||||||
|
|
||||||
|
img := content.FirstChild().(*ast.Image)
|
||||||
|
assert.Equal(ast.KindImage, img.Kind())
|
||||||
|
assert.Equal("https://hello.com/world.png", img.Source())
|
||||||
|
|
||||||
|
t.Logf("%#v", img)
|
||||||
|
}
|
||||||
31
ipub/ast/content.go
Normal file
31
ipub/ast/content.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package ast
|
||||||
|
|
||||||
|
type Content struct {
|
||||||
|
BaseNode
|
||||||
|
}
|
||||||
|
|
||||||
|
var KindContent = NewNodeKind("content", &Content{})
|
||||||
|
|
||||||
|
func (e Content) Kind() NodeKind {
|
||||||
|
return KindContent
|
||||||
|
}
|
||||||
|
|
||||||
|
type Image struct {
|
||||||
|
src string
|
||||||
|
|
||||||
|
BaseNode
|
||||||
|
}
|
||||||
|
|
||||||
|
var KindImage = NewNodeKind("image", &Image{})
|
||||||
|
|
||||||
|
func (e *Image) Kind() NodeKind {
|
||||||
|
return KindImage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Image) Source() string {
|
||||||
|
return e.src
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Image) SetSource(src string) {
|
||||||
|
e.src = src
|
||||||
|
}
|
||||||
11
ipub/ast/package.go
Normal file
11
ipub/ast/package.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package ast
|
||||||
|
|
||||||
|
type Package struct {
|
||||||
|
BaseNode
|
||||||
|
}
|
||||||
|
|
||||||
|
var KindPackage = NewNodeKind("package", &Package{})
|
||||||
|
|
||||||
|
func (e Package) Kind() NodeKind {
|
||||||
|
return KindPackage
|
||||||
|
}
|
||||||
20
ipub/ast/section.go
Normal file
20
ipub/ast/section.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package ast
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Section struct {
|
||||||
|
XMLName xml.Name `xml:"html"`
|
||||||
|
Body *Body `xml:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
BaseNode
|
||||||
|
}
|
||||||
|
|
||||||
|
var KindBody = NewNodeKind("body", &Body{})
|
||||||
|
|
||||||
|
func (e Body) Kind() NodeKind {
|
||||||
|
return KindBody
|
||||||
|
}
|
||||||
29
ipub/element/attr/attr.go
Normal file
29
ipub/element/attr/attr.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package attr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Attribute interface {
|
||||||
|
xml.MarshalerAttr
|
||||||
|
xml.UnmarshalerAttr
|
||||||
|
fmt.Stringer
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseAttribute string
|
||||||
|
|
||||||
|
func (a BaseAttribute) MarshalXMLAttr(n xml.Name) (xml.Attr, error) {
|
||||||
|
return xml.Attr{Name: n, Value: a.String()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *BaseAttribute) UnmarshalXMLAttr(attr xml.Attr) error {
|
||||||
|
*a = BaseAttribute(attr.Value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a BaseAttribute) String() string {
|
||||||
|
return string(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
36
ipub/element/attr/errors.go
Normal file
36
ipub/element/attr/errors.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package attr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrInvalidName struct {
|
||||||
|
Actual xml.Name
|
||||||
|
Expected xml.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ error = ErrInvalidName{}
|
||||||
|
|
||||||
|
func (err ErrInvalidName) Error() string {
|
||||||
|
return fmt.Sprintf("attribute %q has invalid name, expected %q", FmtXMLName(err.Actual), FmtXMLName(err.Expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrInvalidValue struct {
|
||||||
|
Attr xml.Attr
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ error = ErrInvalidValue{}
|
||||||
|
|
||||||
|
func (err ErrInvalidValue) Error() string {
|
||||||
|
return fmt.Sprintf("attribute %q's value %q is invalid: %s", FmtXMLName(err.Attr.Name), err.Attr.Value, err.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FmtXMLName(n xml.Name) string {
|
||||||
|
s := n.Local
|
||||||
|
if n.Space != "" {
|
||||||
|
s = fmt.Sprintf("%s:%s", n.Space, n.Local)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
3
ipub/element/attr/ipub_data.go
Normal file
3
ipub/element/attr/ipub_data.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package attr
|
||||||
|
|
||||||
|
type DataElement = BaseAttribute
|
||||||
112
ipub/element/element.go
Normal file
112
ipub/element/element.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package element
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/ipub/element/attr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Element interface {
|
||||||
|
Kind() ElementKind
|
||||||
|
}
|
||||||
|
|
||||||
|
type ElementChildren []Element
|
||||||
|
|
||||||
|
func (ec *ElementChildren) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||||
|
elErr := fmt.Errorf("unable to unsmarshal element %q", attr.FmtXMLName(start.Name))
|
||||||
|
|
||||||
|
i := slices.IndexFunc(start.Attr, func(a xml.Attr) bool {
|
||||||
|
return a.Name == elementKindAttrName
|
||||||
|
})
|
||||||
|
if i == -1 {
|
||||||
|
return errors.Join(elErr, fmt.Errorf("element kind not specified"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var k ElementKind
|
||||||
|
if err := k.UnmarshalXMLAttr(start.Attr[i]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ks := elementKindList[k]
|
||||||
|
|
||||||
|
// Get a pointer of a new instance of the underlying implementation so we can
|
||||||
|
// change it without manipulating the value inside the elementKindList.
|
||||||
|
ep := reflect.New(reflect.TypeOf(ks))
|
||||||
|
if ep.Elem().Kind() == reflect.Pointer {
|
||||||
|
// If the implementation is a pointer, we need the underlying value so we can
|
||||||
|
// manipulate it.
|
||||||
|
ep = reflect.New(reflect.TypeOf(ks).Elem())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := d.DecodeElement(ep.Interface(), &start); err != nil && err != io.EOF {
|
||||||
|
return errors.Join(elErr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ec == nil {
|
||||||
|
c := ElementChildren{}
|
||||||
|
ec = &c
|
||||||
|
}
|
||||||
|
|
||||||
|
s := *ec
|
||||||
|
s = append(s, ep.Interface().(Element))
|
||||||
|
*ec = s
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ElementKind string
|
||||||
|
|
||||||
|
// NewElementKind registers a new Element implementation to a private list which is
|
||||||
|
// consumed bu [ElementChildren] to properly find what underlying type is a children
|
||||||
|
// of another element struct.
|
||||||
|
func NewElementKind(n string, s Element) ElementKind {
|
||||||
|
k := ElementKind(n)
|
||||||
|
|
||||||
|
if _, ok := elementKindList[k]; ok {
|
||||||
|
panic(fmt.Sprintf("element kind %q already registered", n))
|
||||||
|
}
|
||||||
|
|
||||||
|
elementKindList[k] = s
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k ElementKind) MarshalXMLAttr(n xml.Name) (xml.Attr, error) {
|
||||||
|
if n != elementKindAttrName {
|
||||||
|
return xml.Attr{}, attr.ErrInvalidName{Actual: n, Expected: elementKindAttrName}
|
||||||
|
}
|
||||||
|
return xml.Attr{Name: elementKindAttrName, Value: k.String()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *ElementKind) UnmarshalXMLAttr(a xml.Attr) error {
|
||||||
|
ak := ElementKind(a.Value)
|
||||||
|
|
||||||
|
if _, ok := elementKindList[ak]; !ok {
|
||||||
|
v := make([]string, 0, len(elementKindList))
|
||||||
|
for k := range elementKindList {
|
||||||
|
v = append(v, k.String())
|
||||||
|
}
|
||||||
|
return attr.ErrInvalidValue{
|
||||||
|
Attr: a,
|
||||||
|
Message: fmt.Sprintf("must be a registered element (%q)", strings.Join(v, `", "`)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*k = ak
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k ElementKind) String() string {
|
||||||
|
return string(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
elementKindList = make(map[ElementKind]Element)
|
||||||
|
elementKindAttrName = xml.Name{Local: "data-ipub-element"}
|
||||||
|
)
|
||||||
47
ipub/element/element_test.go
Normal file
47
ipub/element/element_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package element_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/ipub/element"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test(t *testing.T) {
|
||||||
|
d := element.Section{
|
||||||
|
Body: element.Body{
|
||||||
|
Test: "helloworld",
|
||||||
|
Children: []element.Element{
|
||||||
|
&element.Paragraph{
|
||||||
|
DataElement: element.ParagraphKind,
|
||||||
|
Text: "hello world",
|
||||||
|
Test: "testvalue",
|
||||||
|
},
|
||||||
|
&element.Paragraph{
|
||||||
|
DataElement: element.ParagraphKind,
|
||||||
|
|
||||||
|
Text: "hello world 2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := xml.Marshal(d)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err.Error())
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("%#v", string(b))
|
||||||
|
|
||||||
|
var ud element.Section
|
||||||
|
err = xml.Unmarshal(b, &ud)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("%#v", ud)
|
||||||
|
t.Logf("%#v", ud.Body.Children[0])
|
||||||
|
t.Logf("%#v", ud.Body.Children[1])
|
||||||
|
}
|
||||||
41
ipub/element/sections.go
Normal file
41
ipub/element/sections.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package element
|
||||||
|
|
||||||
|
import "encoding/xml"
|
||||||
|
|
||||||
|
type Section struct {
|
||||||
|
XMLName xml.Name `xml:"html"`
|
||||||
|
Body Body `xml:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var KindSection = NewElementKind("section", Section{})
|
||||||
|
|
||||||
|
func (Section) Kind() ElementKind {
|
||||||
|
return KindSection
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
XMLName xml.Name `xml:"body"`
|
||||||
|
Test string `xml:"test,attr"`
|
||||||
|
|
||||||
|
Children ElementChildren `xml:",any"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var KindBody = NewElementKind("body", Body{})
|
||||||
|
|
||||||
|
func (Body) Kind() ElementKind {
|
||||||
|
return KindBody
|
||||||
|
}
|
||||||
|
|
||||||
|
type Paragraph struct {
|
||||||
|
XMLName xml.Name `xml:"p"`
|
||||||
|
DataElement ElementKind `xml:"data-ipub-element,attr"`
|
||||||
|
Test string `xml:"test,attr"`
|
||||||
|
|
||||||
|
Text string `xml:",chardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var KindParagraph = NewElementKind("paragraph", Paragraph{})
|
||||||
|
|
||||||
|
func (Paragraph) Kind() ElementKind {
|
||||||
|
return KindParagraph
|
||||||
|
}
|
||||||
7
main.go
7
main.go
@@ -1,7 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "forge.capytal.company/capytalcode/project-comicverse/cmd"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cmd.Execute()
|
|
||||||
}
|
|
||||||
36
makefile
36
makefile
@@ -10,26 +10,40 @@ fmt:
|
|||||||
dev/server:
|
dev/server:
|
||||||
go run github.com/joho/godotenv/cmd/godotenv@v1.5.1 \
|
go run github.com/joho/godotenv/cmd/godotenv@v1.5.1 \
|
||||||
go run github.com/air-verse/air@v1.52.2 \
|
go run github.com/air-verse/air@v1.52.2 \
|
||||||
--build.cmd "go build -o .tmp/bin/main ." \
|
--build.cmd "go build -o tmp/bin/main ./cmd" \
|
||||||
--build.bin ".tmp/bin/main" \
|
--build.bin "tmp/bin/main" \
|
||||||
--build.exclude_dir "node_modules" \
|
--build.exclude_dir "node_modules" \
|
||||||
--build.include_ext "go,html" \
|
--build.include_ext "go" \
|
||||||
--build.stop_on_error "false" \
|
--build.stop_on_error "false" \
|
||||||
--proxy.enabled true \
|
|
||||||
--proxy.proxy_port $(PORT) \
|
|
||||||
--proxy.app_port $$(($(PORT) + 1)) \
|
|
||||||
--misc.clean_on_exit true \
|
--misc.clean_on_exit true \
|
||||||
-- -dev -port $$(($(PORT) + 1))
|
-- -dev -port $(PORT) -hostname 0.0.0.0
|
||||||
|
|
||||||
dev/assets:
|
dev/assets:
|
||||||
tailwindcss -o ./static/css/wind.css -w
|
tailwindcss \
|
||||||
|
-i ./assets/stylesheets/tailwind.css \
|
||||||
|
-o ./assets/stylesheets/out.css \
|
||||||
|
--watch
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
$(MAKE) -j2 dev/server dev/assets
|
$(MAKE) -j2 dev/assets dev/server
|
||||||
|
|
||||||
|
dev/debug:
|
||||||
|
$(MAKE) -j2 debug dev/assets
|
||||||
|
|
||||||
build:
|
debug:
|
||||||
go generate
|
dlv debug -l 127.0.0.1:38697 \
|
||||||
|
--continue \
|
||||||
|
--accept-multiclient \
|
||||||
|
--headless \
|
||||||
|
./cmd -- -dev -port $(PORT) -hostname 0.0.0.0
|
||||||
|
|
||||||
|
build/assets:
|
||||||
|
tailwindcss \
|
||||||
|
-i ./assets/stylesheets/tailwind.css \
|
||||||
|
-o ./assets/stylesheets/out.css \
|
||||||
|
--minify
|
||||||
|
|
||||||
|
build: build/assets
|
||||||
go build -o ./.dist/app .
|
go build -o ./.dist/app .
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
|
|||||||
13
model/user.go
Normal file
13
model/user.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Username string `json:"username"` // Must be unique
|
||||||
|
Password []byte `json:"password"`
|
||||||
|
|
||||||
|
DateCreated time.Time `json:"date_created"`
|
||||||
|
DateUpdated time.Time `json:"date_updated"`
|
||||||
|
}
|
||||||
16
repository/repository.go
Normal file
16
repository/repository.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
)
|
||||||
|
var (
|
||||||
|
ErrDatabaseConn = errors.New("failed to begin transaction/connection with database")
|
||||||
|
ErrExecuteQuery = errors.New("failed to execute query")
|
||||||
|
ErrCommitQuery = errors.New("failed to commit transaction")
|
||||||
|
ErrInvalidData = errors.New("data sent to save is invalid")
|
||||||
|
ErrNotFound = sql.ErrNoRows
|
||||||
|
)
|
||||||
|
|
||||||
|
var dateFormat = time.RFC3339
|
||||||
173
repository/users.go
Normal file
173
repository/users.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/model"
|
||||||
|
"forge.capytal.company/loreddev/x/tinyssert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
log *slog.Logger
|
||||||
|
assert tinyssert.Assertions
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRepository(
|
||||||
|
db *sql.DB,
|
||||||
|
ctx context.Context,
|
||||||
|
logger *slog.Logger,
|
||||||
|
assert tinyssert.Assertions,
|
||||||
|
) (*UserRepository, error) {
|
||||||
|
assert.NotNil(db)
|
||||||
|
assert.NotNil(ctx)
|
||||||
|
assert.NotNil(logger)
|
||||||
|
|
||||||
|
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username TEXT NOT NULL PRIMARY KEY,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UserRepository{
|
||||||
|
db: db,
|
||||||
|
ctx: ctx,
|
||||||
|
log: logger,
|
||||||
|
assert: assert,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Create(u model.User) (model.User, error) {
|
||||||
|
r.assert.NotNil(r.db)
|
||||||
|
r.assert.NotNil(r.log)
|
||||||
|
r.assert.NotNil(r.ctx)
|
||||||
|
|
||||||
|
tx, err := r.db.BeginTx(r.ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `
|
||||||
|
INSERT INTO users (username, password_hash, created_at, updated_at)
|
||||||
|
VALUES (:username, :password_hash, :created_at, :updated_at)
|
||||||
|
`
|
||||||
|
|
||||||
|
log := r.log.With(slog.String("username", u.Username), slog.String("query", q))
|
||||||
|
log.DebugContext(r.ctx, "Inserting new user")
|
||||||
|
|
||||||
|
t := time.Now()
|
||||||
|
|
||||||
|
passwd := base64.URLEncoding.EncodeToString(u.Password)
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(r.ctx, q,
|
||||||
|
sql.Named("username", u.Username),
|
||||||
|
sql.Named("password_hash", passwd),
|
||||||
|
sql.Named("created_at", t.Format(dateFormat)),
|
||||||
|
sql.Named("updated_at", t.Format(dateFormat)))
|
||||||
|
if err != nil {
|
||||||
|
log.ErrorContext(r.ctx, "Failed to create user", slog.String("error", err.Error()))
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
log.ErrorContext(r.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByUsername(username string) (model.User, error) {
|
||||||
|
r.assert.NotNil(r.db)
|
||||||
|
r.assert.NotNil(r.log)
|
||||||
|
r.assert.NotNil(r.ctx)
|
||||||
|
|
||||||
|
tx, err := r.db.BeginTx(r.ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `
|
||||||
|
SELECT username, password_hash, created_at, updated_at FROM users
|
||||||
|
WHERE username = :username
|
||||||
|
`
|
||||||
|
|
||||||
|
log := r.log.With(slog.String("username", username), slog.String("query", q))
|
||||||
|
log.DebugContext(r.ctx, "Querying user")
|
||||||
|
|
||||||
|
row := tx.QueryRowContext(r.ctx, q, sql.Named("username", username))
|
||||||
|
|
||||||
|
var password_hash, dateCreated, dateUpdated string
|
||||||
|
if err = row.Scan(&username, &password_hash, &dateCreated, &dateUpdated); err != nil {
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
log.ErrorContext(r.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
passwd, err := base64.URLEncoding.DecodeString(password_hash)
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := time.Parse(dateFormat, dateCreated)
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, errors.Join(ErrInvalidData, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := time.Parse(dateFormat, dateUpdated)
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, errors.Join(ErrInvalidData, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.User{
|
||||||
|
Username: username,
|
||||||
|
Password: passwd,
|
||||||
|
DateCreated: c,
|
||||||
|
DateUpdated: u,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Delete(u model.User) error {
|
||||||
|
r.assert.NotNil(r.db)
|
||||||
|
r.assert.NotNil(r.log)
|
||||||
|
r.assert.NotNil(r.ctx)
|
||||||
|
|
||||||
|
tx, err := r.db.BeginTx(r.ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `
|
||||||
|
DELETE FROM users WHERE username = :username
|
||||||
|
`
|
||||||
|
|
||||||
|
log := r.log.With(slog.String("username", u.Username), slog.String("query", q))
|
||||||
|
log.DebugContext(r.ctx, "Deleting user")
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(r.ctx, q, sql.Named("username", u.Username))
|
||||||
|
if err != nil {
|
||||||
|
log.ErrorContext(r.ctx, "Failed to delete user", slog.String("error", err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
log.ErrorContext(r.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
108
router/router.go
108
router/router.go
@@ -2,9 +2,11 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/service"
|
||||||
"forge.capytal.company/capytalcode/project-comicverse/templates"
|
"forge.capytal.company/capytalcode/project-comicverse/templates"
|
||||||
"forge.capytal.company/loreddev/x/smalltrip"
|
"forge.capytal.company/loreddev/x/smalltrip"
|
||||||
"forge.capytal.company/loreddev/x/smalltrip/exception"
|
"forge.capytal.company/loreddev/x/smalltrip/exception"
|
||||||
@@ -12,41 +14,105 @@ import (
|
|||||||
"forge.capytal.company/loreddev/x/tinyssert"
|
"forge.capytal.company/loreddev/x/tinyssert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(assertions tinyssert.Assertions, log *slog.Logger, dev bool) http.Handler {
|
type router struct {
|
||||||
|
userService *service.UserService
|
||||||
|
|
||||||
|
templates templates.ITemplate
|
||||||
|
assets fs.FS
|
||||||
|
cache bool
|
||||||
|
|
||||||
|
assert tinyssert.Assertions
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) (http.Handler, error) {
|
||||||
|
if cfg.UserService == nil {
|
||||||
|
return nil, errors.New("user service is nil")
|
||||||
|
}
|
||||||
|
if cfg.Templates == nil {
|
||||||
|
return nil, errors.New("templates is nil")
|
||||||
|
}
|
||||||
|
if cfg.Assets == nil {
|
||||||
|
return nil, errors.New("static files is nil")
|
||||||
|
}
|
||||||
|
if cfg.Assertions == nil {
|
||||||
|
return nil, errors.New("assertions is nil")
|
||||||
|
}
|
||||||
|
if cfg.Logger == nil {
|
||||||
|
return nil, errors.New("logger is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &router{
|
||||||
|
userService: cfg.UserService,
|
||||||
|
|
||||||
|
templates: cfg.Templates,
|
||||||
|
assets: cfg.Assets,
|
||||||
|
cache: !cfg.DisableCache,
|
||||||
|
|
||||||
|
assert: cfg.Assertions,
|
||||||
|
log: cfg.Logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.setup(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
UserService *service.UserService
|
||||||
|
|
||||||
|
Templates templates.ITemplate
|
||||||
|
Assets fs.FS
|
||||||
|
DisableCache bool
|
||||||
|
|
||||||
|
Assertions tinyssert.Assertions
|
||||||
|
Logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (router *router) setup() http.Handler {
|
||||||
|
router.assert.NotNil(router.log)
|
||||||
|
router.assert.NotNil(router.assets)
|
||||||
|
|
||||||
|
log := router.log
|
||||||
|
|
||||||
|
log.Debug("Initializing router")
|
||||||
|
|
||||||
r := smalltrip.NewRouter(
|
r := smalltrip.NewRouter(
|
||||||
smalltrip.WithAssertions(assertions),
|
smalltrip.WithAssertions(router.assert),
|
||||||
smalltrip.WithLogger(log.WithGroup("smalltrip")),
|
smalltrip.WithLogger(log.WithGroup("smalltrip")),
|
||||||
)
|
)
|
||||||
|
|
||||||
r.Use(middleware.Logger(log.WithGroup("requests")))
|
r.Use(middleware.Logger(log.WithGroup("requests")))
|
||||||
if dev {
|
if router.cache {
|
||||||
log.Debug("Development mode activated, using development middleware")
|
r.Use(middleware.Cache())
|
||||||
r.Use(middleware.Dev)
|
|
||||||
} else {
|
} else {
|
||||||
r.Use(middleware.PersistentCache())
|
r.Use(middleware.DisableCache())
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Use(exception.PanicMiddleware())
|
r.Use(exception.PanicMiddleware())
|
||||||
r.Use(exception.Middleware())
|
r.Use(exception.Middleware())
|
||||||
|
|
||||||
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
userController := newUserController(router.userService, router.templates, router.assert)
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
err := templates.Templates().ExecuteTemplate(w, "index.html", nil)
|
r.Handle("/assets/", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
|
||||||
|
|
||||||
|
r.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// TODO: Add a way to the user to bypass this check and see the landing page.
|
||||||
|
// Probably a query parameter to bypass like "?landing=true"
|
||||||
|
if userController.isLogged(r) {
|
||||||
|
err := router.templates.ExecuteTemplate(w, "dashboard", nil)
|
||||||
|
if err != nil {
|
||||||
|
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := router.templates.ExecuteTemplate(w, "landing", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
r.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
|
|
||||||
r.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
|
r.HandleFunc("/login/{$}", userController.login)
|
||||||
exception.InternalServerError(errors.New("TEST ERROR"),
|
r.HandleFunc("/register/{$}", userController.register)
|
||||||
exception.WithData("test-data", "test-value"),
|
|
||||||
).ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
r.HandleFunc("/panic", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
panic("TEST PANIC")
|
|
||||||
})
|
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func dashboard(w http.ResponseWriter, r *http.Request) {
|
|
||||||
}
|
|
||||||
|
|||||||
139
router/users.go
Normal file
139
router/users.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/service"
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/templates"
|
||||||
|
"forge.capytal.company/loreddev/x/smalltrip/exception"
|
||||||
|
"forge.capytal.company/loreddev/x/tinyssert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userController struct {
|
||||||
|
assert tinyssert.Assertions
|
||||||
|
templates templates.ITemplate
|
||||||
|
service *service.UserService
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUserController(
|
||||||
|
service *service.UserService,
|
||||||
|
templates templates.ITemplate,
|
||||||
|
assert tinyssert.Assertions,
|
||||||
|
) userController {
|
||||||
|
return userController{
|
||||||
|
assert: assert,
|
||||||
|
templates: templates,
|
||||||
|
service: service,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c userController) login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c.assert.NotNil(c.templates)
|
||||||
|
c.assert.NotNil(c.service)
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
err := c.templates.ExecuteTemplate(w, "login", nil)
|
||||||
|
if err != nil {
|
||||||
|
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
exception.MethodNotAllowed([]string{http.MethodGet, http.MethodPost}).
|
||||||
|
ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, passwd := r.FormValue("username"), r.FormValue("password")
|
||||||
|
if user == "" {
|
||||||
|
exception.BadRequest(errors.New(`missing "username" form value`)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if passwd == "" {
|
||||||
|
exception.BadRequest(errors.New(`missing "password" form value`)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Move token issuing to it's own service, make UserService.Login just return the user
|
||||||
|
token, _, err := c.service.Login(user, passwd)
|
||||||
|
if errors.Is(err, service.ErrNotFound) {
|
||||||
|
exception.NotFound(exception.WithError(errors.New("user not found"))).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: harden the cookie policy to the same domain
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Name: "token",
|
||||||
|
Value: token,
|
||||||
|
}
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctrl userController) register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctrl.assert.NotNil(ctrl.templates)
|
||||||
|
ctrl.assert.NotNil(ctrl.service)
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
err := ctrl.templates.ExecuteTemplate(w, "register", nil)
|
||||||
|
if err != nil {
|
||||||
|
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
exception.MethodNotAllowed([]string{http.MethodGet, http.MethodPost}).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, passwd := r.FormValue("username"), r.FormValue("password")
|
||||||
|
if user == "" {
|
||||||
|
exception.BadRequest(errors.New(`missing "username" form value`)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if passwd == "" {
|
||||||
|
exception.BadRequest(errors.New(`missing "password" form value`)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ctrl.service.Register(user, passwd)
|
||||||
|
if err != nil {
|
||||||
|
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Move token issuing to it's own service, make UserService.Login just return the user
|
||||||
|
token, _, err := ctrl.service.Login(user, passwd)
|
||||||
|
if err == service.ErrNotFound {
|
||||||
|
exception.NotFound(exception.WithError(errors.New("user not found"))).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: harden the cookie policy to the same domain
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Name: "token",
|
||||||
|
Value: token,
|
||||||
|
}
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctrl userController) isLogged(r *http.Request) bool {
|
||||||
|
// TODO: Check if token in valid (depends on token service being implemented)
|
||||||
|
cs := r.CookiesNamed("token")
|
||||||
|
return len(cs) > 0
|
||||||
|
}
|
||||||
1
service/service.go
Normal file
1
service/service.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package service
|
||||||
34
service/token.go
Normal file
34
service/token.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/model"
|
||||||
|
"forge.capytal.company/loreddev/x/tinyssert"
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TokenService struct {
|
||||||
|
assert tinyssert.Assertions
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTokenService(assert tinyssert.Assertions) *TokenService {
|
||||||
|
return &TokenService{assert: assert}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TokenService) Issue(user model.User) (*jwt.Token, error) {
|
||||||
|
id, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
t := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.RegisteredClaims{
|
||||||
|
ID: id.String(),
|
||||||
|
Subject: user.Username,
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
})
|
||||||
|
}
|
||||||
98
service/user.go
Normal file
98
service/user.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/model"
|
||||||
|
"forge.capytal.company/capytalcode/project-comicverse/repository"
|
||||||
|
"forge.capytal.company/loreddev/x/tinyssert"
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserService struct {
|
||||||
|
assert tinyssert.Assertions
|
||||||
|
repo *repository.UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserService(repo *repository.UserRepository, assert tinyssert.Assertions) (*UserService, error) {
|
||||||
|
if err := assert.NotNil(repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UserService{repo: repo, assert: assert}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) Register(username, password string) (model.User, error) {
|
||||||
|
s.assert.NotNil(s.repo)
|
||||||
|
|
||||||
|
if _, err := s.repo.GetByUsername(username); err == nil {
|
||||||
|
return model.User{}, ErrAlreadyExists
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
u := model.User{
|
||||||
|
Username: username,
|
||||||
|
Password: hash,
|
||||||
|
DateCreated: time.Now(),
|
||||||
|
DateUpdated: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err = s.repo.Create(u)
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, errors.Join(errors.New("failed to create user model"), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) Login(username, password string) (signedToken string, user model.User, err error) {
|
||||||
|
s.assert.NotNil(s.repo)
|
||||||
|
|
||||||
|
user, err = s.repo.GetByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
return "", model.User{}, errors.Join(errors.New("unable to find user"), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = bcrypt.CompareHashAndPassword(user.Password, []byte(password))
|
||||||
|
if err != nil {
|
||||||
|
return "", model.User{}, errors.Join(errors.New("unable to compare passwords"), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t := time.Now()
|
||||||
|
jti, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return "", model.User{}, errors.Join(errors.New("unable to generate token ID"), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Use ECDSA, so users can verify that their token is signed by the project
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
|
||||||
|
// TODO: Add IDs to users
|
||||||
|
Issuer: "comicverse",
|
||||||
|
Subject: username,
|
||||||
|
IssuedAt: jwt.NewNumericDate(t),
|
||||||
|
NotBefore: jwt.NewNumericDate(t),
|
||||||
|
ID: jti.String(),
|
||||||
|
})
|
||||||
|
signedToken, err = token.SignedString(jwtKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", user, errors.Join(errors.New("unable to sign token"), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return signedToken, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var jwtKey = []byte("ieurqpieurqpoiweurpewoqueiur") // TODO: move to environment variable
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAlreadyExists = errors.New("model already exists")
|
||||||
|
ErrNotFound = repository.ErrNotFound
|
||||||
|
ErrPasswordTooLong = bcrypt.ErrPasswordTooLong
|
||||||
|
ErrIncorrectPassword = bcrypt.ErrMismatchedHashAndPassword
|
||||||
|
)
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package static
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"io/fs"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:generate tailwindcss -o static/css/wind.css
|
|
||||||
|
|
||||||
//go:embed css/*.css
|
|
||||||
var staticFiles embed.FS
|
|
||||||
|
|
||||||
func Files(local ...bool) fs.FS {
|
|
||||||
var l bool
|
|
||||||
if len(local) > 0 {
|
|
||||||
l = local[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
if !l {
|
|
||||||
return staticFiles
|
|
||||||
}
|
|
||||||
|
|
||||||
return staticFiles
|
|
||||||
}
|
|
||||||
1077
tailwind.config.js
1077
tailwind.config.js
File diff suppressed because it is too large
Load Diff
45
templates/dashboard.html
Normal file
45
templates/dashboard.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{{define "dashboard"}}
|
||||||
|
{{template "layout-page-start" (args "Title" "Dashboard")}}
|
||||||
|
<main class="h-full w-full justify-center px-5 py-10 align-middle">
|
||||||
|
{{if and (ne . nil) (ne (len .) 0)}}
|
||||||
|
<section class="flex h-64 flex-col gap-5">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<h2 class="text-2xl">Projects</h2>
|
||||||
|
<form action="/projects/" method="post">
|
||||||
|
<button class="rounded-full bg-slate-700 p-1 px-3 text-sm text-slate-100">
|
||||||
|
New project
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="grid h-full grid-flow-col grid-rows-1 justify-start gap-5 overflow-scroll">
|
||||||
|
{{range .}}
|
||||||
|
<div class="w-38 grid h-full grid-rows-2 bg-slate-500">
|
||||||
|
<div class="bg-blue-500 p-2">Image</div>
|
||||||
|
<div class="p-2">
|
||||||
|
<a href="/projects/{{.ID}}">
|
||||||
|
<h3>{{.Title}}</h3>
|
||||||
|
<p>{{.ID}}</p>
|
||||||
|
</a>
|
||||||
|
<form action="/projects/{{.ID}}/" method="post">
|
||||||
|
<input type="hidden" name="x-method" value="delete">
|
||||||
|
<button class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{else}}
|
||||||
|
<div class="fixed flex h-screen w-full items-center justify-center top-0 left-0">
|
||||||
|
<form action="/projects/" method="post">
|
||||||
|
<button class="rounded-full bg-slate-700 p-2 px-5 text-slate-100">
|
||||||
|
New project
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
{{template "layout-page-end"}}
|
||||||
|
{{end}}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<link rel="stylesheet" href="/static/css/wind.css">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="text-red-600">
|
|
||||||
<h1>Hello, world</h1>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
20
templates/landing.html
Normal file
20
templates/landing.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{{define "landing"}} {{template "layout-page-start" (args "Title"
|
||||||
|
"ComicVerse")}}
|
||||||
|
<main class="h-full w-full justify-center px-5 py-10 align-middle">
|
||||||
|
<div
|
||||||
|
class="fixed flex flex-col gap-5 h-screen w-full items-center justify-center top-0 left-0"
|
||||||
|
>
|
||||||
|
<h1 class="text-3xl font-bold">Welcome back</h1>
|
||||||
|
<a
|
||||||
|
href="/login/"
|
||||||
|
hx-get="/login/"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-select="#login-form"
|
||||||
|
>
|
||||||
|
<button class="rounded-full bg-slate-700 p-2 px-5 text-slate-100">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{{template "layout-page-end"}} {{end}}
|
||||||
18
templates/layouts/layout_base.html
Normal file
18
templates/layouts/layout_base.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{{define "layout-base-start"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link href="/assets/stylesheets/out.css" rel="stylesheet">
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.js"
|
||||||
|
integrity="sha384-oeUn82QNXPuVkGCkcrInrS1twIxKhkZiFfr2TdiuObZ3n3yIeMiqcRzkIcguaof1"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
</head>
|
||||||
|
{{end}}
|
||||||
|
{{define "layout-base-end"}}
|
||||||
|
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
16
templates/layouts/layout_page.html
Normal file
16
templates/layouts/layout_page.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{{define "layout-page-start"}}
|
||||||
|
{{template "layout-base-start" (args "Title" .Title)}}
|
||||||
|
|
||||||
|
<body class="bg-slate-200 text-slate-950 m-0 min-w-screen min-h-screen relative">
|
||||||
|
<header class="w-full h-7 bg-slate-700 text-slate-50 px-5 flex justify-between top-0 sticky z-100">
|
||||||
|
<h1>Comicverse</h1>
|
||||||
|
<ul>
|
||||||
|
<a href="/dashboard/">Dashboard</a>
|
||||||
|
</ul>
|
||||||
|
</header>
|
||||||
|
{{end}}
|
||||||
|
{{define "layout-page-end"}}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
{{template "layout-base-end"}}
|
||||||
|
{{end}}
|
||||||
32
templates/login.html
Normal file
32
templates/login.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{{define "login"}} {{template "layout-page-start" (args "Title" "Login")}}
|
||||||
|
<main>
|
||||||
|
<div
|
||||||
|
class="w-full h-screen fixed top-0 left-0 flex justify-center items-center"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
action="/login/"
|
||||||
|
method="post"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
class="h-fit bg-slate-500 grid grid-cols-1 grid-rows-3 p-5 gap-3"
|
||||||
|
id="login-form"
|
||||||
|
>
|
||||||
|
<h1>Login</h1>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
class="bg-slate-200 p-1"
|
||||||
|
placeholder="Username"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
class="bg-slate-200 p-1"
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{{template "layout-page-end"}} {{end}}
|
||||||
17
templates/partials/status.html
Normal file
17
templates/partials/status.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{{define "partials-status"}}
|
||||||
|
{{template "layout-page-start" (args "Title" .Title)}}
|
||||||
|
<main class="justify-center align-middle w-full h-full">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1>{{.StatusCode}}</h1>
|
||||||
|
<p>{{.Message}}</p>
|
||||||
|
<a href="{{.Redirect}}">
|
||||||
|
{{if .RedirectMessage}}
|
||||||
|
{{.RedirectMessage}}
|
||||||
|
{{else}}
|
||||||
|
Go back
|
||||||
|
{{end}}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{{template "layout-page-end"}}
|
||||||
|
{{end}}
|
||||||
75
templates/project.html
Normal file
75
templates/project.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{{define "project"}}
|
||||||
|
{{template "layout-page-start" (args "Title" .Title)}}
|
||||||
|
<div class="fixed w-full h-full bg-green-500 grid grid-cols-4 grid-rows-1">
|
||||||
|
<nav class="bg-red-500 h-full">
|
||||||
|
<h1>{{.Title}}</h1>
|
||||||
|
<p>{{.ID}}</p>
|
||||||
|
</nav>
|
||||||
|
<main class="overflow-y-scroll flex justify-center col-span-3 py-20">
|
||||||
|
<div class="flex flex-col gap-10 h-fit">
|
||||||
|
{{range $page := .Pages}}
|
||||||
|
<section id="{{$page.ID}}" class="w-fit">
|
||||||
|
<!--
|
||||||
|
INFO: The interaction form could be another page that is shown
|
||||||
|
when "Add Interaction" is clicked. Said page could be also a partial
|
||||||
|
than can replace the current image using htmx, so it is
|
||||||
|
compatible with JavaScript enabled or not.
|
||||||
|
-->
|
||||||
|
<div class="flex flex-row">
|
||||||
|
<form action="/projects/{{$.ID}}/pages/{{$page.ID}}/interactions/" method="post" class="w-100">
|
||||||
|
<div class="flex">
|
||||||
|
{{if (gt (len $page.Interactions) 0)}}
|
||||||
|
<div class="relative flex">
|
||||||
|
<div class="absolute z-2 w-full h-full top-0 left-0">
|
||||||
|
{{range $interactionID, $interaction := $page.Interactions}}
|
||||||
|
<a class="absolute" href="{{$interaction.URL}}"
|
||||||
|
style="top:{{$interaction.Y}}%;left:{{$interaction.X}}%;">
|
||||||
|
<span
|
||||||
|
class="bg-red-200 opacity-10 block w-10 h-10 transform -translate-x-[50%] -translate-y-[50%]"></span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<img src="/projects/{{$.ID}}/pages/{{$page.ID}}/" class="z-1 relative">
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<img src="/projects/{{$.ID}}/pages/{{$page.ID}}/" class="z-1 relative">
|
||||||
|
{{end}}
|
||||||
|
<input type="range" min="0" max="100" name="y" style="writing-mode: vertical-lr;">
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="100" name="x" class="w-full">
|
||||||
|
<input type="url" required name="link" class="bg-slate-300" placeholder="url of interaction">
|
||||||
|
<button class="rounded-full bg-blue-700 p-1 px-3 text-sm text-slate-100">
|
||||||
|
Add interaction
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{if (gt (len $page.Interactions) 0)}}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{{range $interactionID, $interaction := $page.Interactions}}
|
||||||
|
<form action="/projects/{{$.ID}}/pages/{{$page.ID}}/interactions/{{$interactionID}}/"
|
||||||
|
method="post">
|
||||||
|
<input type="hidden" name="x-method" value="delete">
|
||||||
|
<button class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100">
|
||||||
|
🗑️{{$interaction.URL}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<form action="/projects/{{$.ID}}/pages/{{$page.ID}}/" method="post">
|
||||||
|
<input type="hidden" name="x-method" value="delete">
|
||||||
|
<button class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
<form action="/projects/{{.ID}}/pages/" method="post" enctype="multipart/form-data">
|
||||||
|
<input type="file" name="image" required>
|
||||||
|
<button>Add new page</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{{template "layout-page-end"}}
|
||||||
|
{{end}}
|
||||||
32
templates/register.html
Normal file
32
templates/register.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{{define "register"}} {{template "layout-page-start" (args "Title" "Login")}}
|
||||||
|
<main>
|
||||||
|
<div
|
||||||
|
class="w-full h-screen fixed top-0 left-0 flex justify-center items-center"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
action="/register/"
|
||||||
|
method="post"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
class="h-fit bg-slate-500 grid grid-cols-1 grid-rows-3 p-5 gap-3"
|
||||||
|
id="login-form"
|
||||||
|
>
|
||||||
|
<h1>Login</h1>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
class="bg-slate-200 p-1"
|
||||||
|
placeholder="Username"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
class="bg-slate-200 p-1"
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
<button type="submit">Register</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{{template "layout-page-end"}} {{end}}
|
||||||
@@ -1,18 +1,77 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
|
// INFO: This will probably become a new lib in loreddev/x at some point
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed *.html test/*.html
|
var (
|
||||||
|
patterns = []string{"*.html", "layouts/*.html", "partials/*.html"}
|
||||||
|
functions = template.FuncMap{
|
||||||
|
"args": func(pairs ...any) (map[string]any, error) {
|
||||||
|
if len(pairs)%2 != 0 {
|
||||||
|
return nil, errors.New("misaligned map in template arguments")
|
||||||
|
}
|
||||||
|
|
||||||
|
m := make(map[string]any, len(pairs)/2)
|
||||||
|
|
||||||
|
for i := 0; i < len(pairs); i += 2 {
|
||||||
|
key, ok := pairs[i].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("cannot use type %T as map key", pairs[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
m[key] = pairs[i+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed *.html layouts/*.html partials/*.html
|
||||||
var embedded embed.FS
|
var embedded embed.FS
|
||||||
|
|
||||||
var temps = template.Must(template.ParseFS(embedded,
|
var temps = template.Must(template.New("templates").Funcs(functions).ParseFS(embedded, patterns...))
|
||||||
"*.html",
|
|
||||||
"test/*.html",
|
|
||||||
))
|
|
||||||
|
|
||||||
func Templates() *template.Template {
|
func Templates() *template.Template {
|
||||||
return temps
|
return temps // TODO: Support for local templates/hot-reloading without rebuild
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHotTemplates(fsys fs.FS) *HotTemplate {
|
||||||
|
return &HotTemplate{
|
||||||
|
fs: fsys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HotTemplate struct {
|
||||||
|
fs fs.FS
|
||||||
|
template *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *HotTemplate) Execute(wr io.Writer, data any) error {
|
||||||
|
te, err := template.New("hot-templates").Funcs(functions).ParseFS(t.fs, patterns...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return te.Execute(wr, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *HotTemplate) ExecuteTemplate(wr io.Writer, name string, data any) error {
|
||||||
|
te, err := template.New("hot-templates").Funcs(functions).ParseFS(t.fs, patterns...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return te.ExecuteTemplate(wr, name, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ITemplate interface {
|
||||||
|
Execute(wr io.Writer, data any) error
|
||||||
|
ExecuteTemplate(wr io.Writer, name string, data any) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
|
|
||||||
<head>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<h1>Hello, world 2</h1>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
2
x
2
x
Submodule x updated: 0ccb26ab78...c62be87c6a
Reference in New Issue
Block a user