Compare commits
36 Commits
feat/ipub-
...
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
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ out.css
|
||||
.tmp
|
||||
.env
|
||||
*.db
|
||||
tmp
|
||||
|
||||
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
@@ -14,13 +14,7 @@
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"program": "${workspaceFolder}/cmd/cmd.go",
|
||||
"args": [
|
||||
"-dev",
|
||||
"-port",
|
||||
"8080",
|
||||
"-hostname",
|
||||
"0.0.0.0"
|
||||
]
|
||||
"args": ["-dev", "-port", "8080", "-hostname", "0.0.0.0"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
15
cmd/cmd.go
15
cmd/cmd.go
@@ -68,13 +68,6 @@ func init() {
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
assertions := tinyssert.NewDisabledAssertions()
|
||||
if *dev {
|
||||
assertions = tinyssert.NewAssertions(tinyssert.Opts{
|
||||
Panic: true,
|
||||
})
|
||||
}
|
||||
|
||||
level := slog.LevelError
|
||||
if *dev {
|
||||
level = slog.LevelDebug
|
||||
@@ -83,6 +76,14 @@ func main() {
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
|
||||
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()))
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"net/http"
|
||||
|
||||
"forge.capytal.company/capytalcode/project-comicverse/assets"
|
||||
"forge.capytal.company/capytalcode/project-comicverse/database"
|
||||
"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"
|
||||
@@ -30,7 +30,7 @@ func New(cfg Config, opts ...Option) (http.Handler, error) {
|
||||
developmentMode: false,
|
||||
ctx: context.Background(),
|
||||
|
||||
assert: tinyssert.NewAssertions(),
|
||||
assert: tinyssert.New(),
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})),
|
||||
}
|
||||
|
||||
@@ -127,34 +127,18 @@ func (app *app) setup() error {
|
||||
app.assert.NotNil(app.assets)
|
||||
app.assert.NotNil(app.logger)
|
||||
|
||||
var err error
|
||||
|
||||
database, err := database.New(database.Config{
|
||||
SQL: app.db,
|
||||
Context: app.ctx,
|
||||
Assertions: app.assert,
|
||||
Logger: app.logger.WithGroup("database"),
|
||||
})
|
||||
userRepo, err := repository.NewUserRepository(app.db, app.ctx, app.logger, app.assert)
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("unable to create database struct"), err)
|
||||
return err
|
||||
}
|
||||
|
||||
service, err := service.New(service.Config{
|
||||
DB: database,
|
||||
S3: app.s3,
|
||||
Bucket: app.bucket,
|
||||
|
||||
Context: app.ctx,
|
||||
|
||||
Assertions: app.assert,
|
||||
Logger: app.logger.WithGroup("service"),
|
||||
})
|
||||
userService, err := service.NewUserService(userRepo, app.assert)
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("unable to initiate service"), err)
|
||||
return err
|
||||
}
|
||||
|
||||
app.handler, err = router.New(router.Config{
|
||||
Service: service,
|
||||
UserService: userService,
|
||||
|
||||
Templates: app.templates,
|
||||
DisableCache: app.developmentMode,
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"forge.capytal.company/loreddev/x/tinyssert"
|
||||
)
|
||||
|
||||
var ErrNoRows = sql.ErrNoRows
|
||||
|
||||
type Database struct {
|
||||
sql *sql.DB
|
||||
ctx context.Context
|
||||
|
||||
assert tinyssert.Assertions
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func New(cfg Config) (*Database, error) {
|
||||
if cfg.SQL == nil {
|
||||
return nil, errors.New("SQL database interface should not be nil")
|
||||
}
|
||||
if cfg.Context == nil {
|
||||
return nil, errors.New("context interface should not be nil")
|
||||
}
|
||||
if cfg.Assertions == nil {
|
||||
return nil, errors.New("assertions interface should not be nil")
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
return nil, errors.New("logger should not be a nil pointer")
|
||||
}
|
||||
|
||||
db := &Database{
|
||||
sql: cfg.SQL,
|
||||
ctx: cfg.Context,
|
||||
|
||||
assert: cfg.Assertions,
|
||||
log: cfg.Logger,
|
||||
}
|
||||
|
||||
if err := db.setup(); err != nil {
|
||||
return nil, errors.Join(errors.New("error while setting up Database struct"), err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
SQL *sql.DB
|
||||
Context context.Context
|
||||
|
||||
Assertions tinyssert.Assertions
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
func (db *Database) setup() error {
|
||||
db.assert.NotNil(db.sql)
|
||||
db.assert.NotNil(db.ctx)
|
||||
db.assert.NotNil(db.log)
|
||||
|
||||
log := db.log
|
||||
log.Info("Setting up database")
|
||||
|
||||
log.Debug("Pinging database")
|
||||
|
||||
err := db.sql.PingContext(db.ctx)
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("unable to ping database"), err)
|
||||
}
|
||||
|
||||
log.Debug("Creating tables")
|
||||
|
||||
tx, err := db.sql.BeginTx(db.ctx, nil)
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("unable to start transaction to create tables"), err)
|
||||
}
|
||||
|
||||
setups := []func(*sql.Tx) error{
|
||||
db.setupProjects,
|
||||
}
|
||||
|
||||
for _, setup := range setups {
|
||||
err := setup(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("unable to run transaction to create tables"), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type Project struct {
|
||||
ID string
|
||||
Title string
|
||||
}
|
||||
|
||||
func (db *Database) setupProjects(tx *sql.Tx) error {
|
||||
db.assert.NotNil(tx)
|
||||
db.assert.NotNil(db.ctx)
|
||||
|
||||
q := `CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
title TEXT NOT NULL
|
||||
) STRICT`
|
||||
_, err := tx.ExecContext(db.ctx, q)
|
||||
if err != nil {
|
||||
return errors.Join(errors.New(`unable to execute create query to table "projects"`), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Database) CreateProject(id string, title string) (Project, error) {
|
||||
db.assert.NotNil(db.sql)
|
||||
db.assert.NotNil(db.ctx)
|
||||
db.assert.NotNil(db.log)
|
||||
db.assert.NotZero(id)
|
||||
db.assert.NotZero(title)
|
||||
|
||||
q := fmt.Sprintf(`INSERT INTO projects (id, title) VALUES ('%s', '%s')`, id, title)
|
||||
|
||||
db.log.Debug("Inserting into Projects", slog.String("query", q))
|
||||
|
||||
tx, err := db.sql.BeginTx(db.ctx, nil)
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(db.ctx, q)
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
return Project{ID: id, Title: title}, nil
|
||||
}
|
||||
|
||||
func (db *Database) GetProject(id string) (Project, error) {
|
||||
db.assert.NotNil(db.sql)
|
||||
db.assert.NotNil(db.ctx)
|
||||
db.assert.NotNil(db.log)
|
||||
|
||||
q := fmt.Sprintf(`SELECT id, title FROM projects WHERE id = '%s'`, id)
|
||||
|
||||
db.log.Debug("Getting Project", slog.String("query", q))
|
||||
|
||||
tx, err := db.sql.BeginTx(db.ctx, nil)
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
row := tx.QueryRowContext(db.ctx, q)
|
||||
|
||||
p := Project{}
|
||||
err = row.Scan(&p.ID, &p.Title)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (db *Database) ListProjects() ([]Project, error) {
|
||||
db.assert.NotNil(db.sql)
|
||||
db.assert.NotNil(db.ctx)
|
||||
db.assert.NotNil(db.log)
|
||||
|
||||
q := `SELECT id, title FROM projects`
|
||||
|
||||
db.log.Debug("Listing Projects", slog.String("query", q))
|
||||
|
||||
tx, err := db.sql.BeginTx(db.ctx, nil)
|
||||
if err != nil {
|
||||
return []Project{}, err
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(db.ctx, q)
|
||||
if err != nil {
|
||||
db.assert.Nil(tx.Rollback())
|
||||
return []Project{}, err
|
||||
}
|
||||
|
||||
ps := []Project{}
|
||||
for rows.Next() {
|
||||
p := Project{}
|
||||
|
||||
err := rows.Scan(&p.ID, &p.Title)
|
||||
if err != nil {
|
||||
db.assert.Nil(tx.Rollback())
|
||||
return ps, err
|
||||
}
|
||||
|
||||
ps = append(ps, p)
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return ps, err
|
||||
}
|
||||
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func (db *Database) DeleteProject(id string) error {
|
||||
db.assert.NotNil(db.sql)
|
||||
db.assert.NotNil(db.ctx)
|
||||
db.assert.NotNil(db.log)
|
||||
db.assert.NotZero(id)
|
||||
|
||||
q := fmt.Sprintf(`DELETE FROM projects WHERE id = '%s'`, id)
|
||||
|
||||
db.log.Debug("Deleting from Projects", slog.String("query", q))
|
||||
|
||||
tx, err := db.sql.BeginTx(db.ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(db.ctx, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -19,7 +19,13 @@
|
||||
devShells = forAllSystems (system: pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
CGO_ENABLED = "1";
|
||||
hardeningDisable = ["all"];
|
||||
hardeningDisable = ["fortify"];
|
||||
|
||||
shellHook = ''
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
'';
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
# Go tools
|
||||
|
||||
3
go.mod
3
go.mod
@@ -6,7 +6,10 @@ 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 (
|
||||
|
||||
6
go.sum
6
go.sum
@@ -24,14 +24,20 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1 h1:1M0gSbyP6q06gl3384wpoKPaH9G16
|
||||
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=
|
||||
|
||||
10
makefile
10
makefile
@@ -27,6 +27,16 @@ dev/assets:
|
||||
dev:
|
||||
$(MAKE) -j2 dev/assets dev/server
|
||||
|
||||
dev/debug:
|
||||
$(MAKE) -j2 debug dev/assets
|
||||
|
||||
debug:
|
||||
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 \
|
||||
|
||||
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
|
||||
}
|
||||
283
router/editor.go
283
router/editor.go
@@ -1,283 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"forge.capytal.company/capytalcode/project-comicverse/internals/randstr"
|
||||
"forge.capytal.company/capytalcode/project-comicverse/service"
|
||||
"forge.capytal.company/loreddev/x/smalltrip/exception"
|
||||
)
|
||||
|
||||
func (router *router) pages(w http.ResponseWriter, r *http.Request) {
|
||||
router.assert.NotNil(w)
|
||||
router.assert.NotNil(r)
|
||||
|
||||
// TODO: Check if project exists
|
||||
id := r.PathValue("ID")
|
||||
if id == "" {
|
||||
exception.
|
||||
BadRequest(fmt.Errorf(`a valid path value of "ID" must be provided`)).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
pageID := r.PathValue("PageID")
|
||||
|
||||
switch getMethod(r) {
|
||||
case http.MethodGet, http.MethodHead:
|
||||
if pageID == "" {
|
||||
exception.
|
||||
BadRequest(fmt.Errorf(`a valid path value of "PageID" must be provided`)).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
router.getPage(w, r)
|
||||
|
||||
case http.MethodPost:
|
||||
router.addPage(w, r)
|
||||
|
||||
case http.MethodDelete:
|
||||
if pageID == "" {
|
||||
exception.
|
||||
BadRequest(fmt.Errorf(`a valid path value of "PageID" must be provided`)).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
router.deletePage(w, r)
|
||||
|
||||
default:
|
||||
exception.
|
||||
MethodNotAllowed([]string{
|
||||
http.MethodGet,
|
||||
http.MethodHead,
|
||||
http.MethodPost,
|
||||
http.MethodDelete,
|
||||
}).
|
||||
ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (router *router) addPage(w http.ResponseWriter, r *http.Request) {
|
||||
router.assert.NotNil(w)
|
||||
router.assert.NotNil(r)
|
||||
router.assert.NotNil(router.service)
|
||||
|
||||
id := r.PathValue("ID")
|
||||
router.assert.NotZero(id, "This method should be used after the path values are checked")
|
||||
|
||||
img, _, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
// TODO: Handle if the file is bigger than allowed by ParseForm (10mb)
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err = router.service.AddPage(id, img)
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/projects/%s/", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (router *router) getPage(w http.ResponseWriter, r *http.Request) {
|
||||
router.assert.NotNil(w)
|
||||
router.assert.NotNil(r)
|
||||
router.assert.NotNil(router.service)
|
||||
|
||||
id := r.PathValue("ID")
|
||||
router.assert.NotZero(id, "This method should be used after the path values are checked")
|
||||
|
||||
pageID := r.PathValue("PageID")
|
||||
router.assert.NotZero(pageID, "This method should be used after the path values are checked")
|
||||
|
||||
page, err := router.service.GetPage(id, pageID)
|
||||
if errors.Is(err, service.ErrPageNotExists) {
|
||||
exception.NotFound(exception.WithError(err)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if i, ok := page.Image.(io.WriterTo); ok {
|
||||
_, err = i.WriteTo(w)
|
||||
} else {
|
||||
_, err = io.Copy(w, page.Image)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (router *router) deletePage(w http.ResponseWriter, r *http.Request) {
|
||||
router.assert.NotNil(w)
|
||||
router.assert.NotNil(r)
|
||||
router.assert.NotNil(router.service)
|
||||
|
||||
id := r.PathValue("ID")
|
||||
router.assert.NotZero(id, "This method should be used after the path values are checked")
|
||||
|
||||
pageID := r.PathValue("PageID")
|
||||
router.assert.NotZero(pageID, "This method should be used after the path values are checked")
|
||||
|
||||
err := router.service.DeletePage(id, pageID)
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/projects/%s/", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (router *router) interactions(w http.ResponseWriter, r *http.Request) {
|
||||
router.assert.NotNil(w)
|
||||
router.assert.NotNil(r)
|
||||
|
||||
// TODO: Check if the project exists
|
||||
id := r.PathValue("ID")
|
||||
if id == "" {
|
||||
exception.
|
||||
BadRequest(fmt.Errorf(`a valid path value of "ID" must be provided`)).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Check if page exists
|
||||
pageID := r.PathValue("PageID")
|
||||
if pageID == "" {
|
||||
exception.
|
||||
BadRequest(fmt.Errorf(`a valid path value of "PageID" must be provided`)).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
interactionID := r.PathValue("InteractionID")
|
||||
|
||||
switch getMethod(r) {
|
||||
case http.MethodPost:
|
||||
router.addInteraction(w, r)
|
||||
|
||||
case http.MethodDelete:
|
||||
if interactionID == "" {
|
||||
exception.
|
||||
BadRequest(fmt.Errorf(`a valid path value of "InteractionID" must be provided`)).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
router.deleteInteraction(w, r)
|
||||
|
||||
default:
|
||||
exception.
|
||||
MethodNotAllowed([]string{
|
||||
http.MethodPost,
|
||||
http.MethodDelete,
|
||||
}).
|
||||
ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (router *router) addInteraction(w http.ResponseWriter, r *http.Request) {
|
||||
router.assert.NotNil(w)
|
||||
router.assert.NotNil(r)
|
||||
router.assert.NotNil(router.service)
|
||||
|
||||
id := r.PathValue("ID")
|
||||
router.assert.NotZero(id, "This method should be used after the path values are checked")
|
||||
|
||||
pageID := r.PathValue("PageID")
|
||||
router.assert.NotZero(pageID, "This method should be used after the path values are checked")
|
||||
|
||||
// TODO: Methods to manipulate interactions, instead of router need to do this logic
|
||||
page, err := router.service.GetPage(id, pageID)
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
page.Image = nil // HACK: Prevent image update on S3
|
||||
|
||||
x, err := strconv.ParseUint(r.FormValue("x"), 10, 0)
|
||||
if err != nil {
|
||||
exception.
|
||||
BadRequest(errors.Join(errors.New(`value "x" should be a valid non-negative integer`), err)).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
y, err := strconv.ParseUint(r.FormValue("y"), 10, 0)
|
||||
if err != nil {
|
||||
exception.
|
||||
BadRequest(errors.Join(errors.New(`value "y" should be a valid non-negative integer`), err)).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
link := r.FormValue("link")
|
||||
if link == "" {
|
||||
exception.BadRequest(errors.New(`missing parameter "link" in request`)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
intID, err := randstr.NewHex(6)
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
page.Interactions[intID] = service.PageInteraction{
|
||||
X: uint16(x),
|
||||
Y: uint16(y),
|
||||
URL: link,
|
||||
}
|
||||
|
||||
err = router.service.UpdatePage(id, page)
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/projects/%s/", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (router *router) deleteInteraction(w http.ResponseWriter, r *http.Request) {
|
||||
router.assert.NotNil(w)
|
||||
router.assert.NotNil(r)
|
||||
router.assert.NotNil(router.service)
|
||||
|
||||
id := r.PathValue("ID")
|
||||
router.assert.NotZero(id, "This method should be used after the path values are checked")
|
||||
|
||||
pageID := r.PathValue("PageID")
|
||||
router.assert.NotZero(pageID, "This method should be used after the path values are checked")
|
||||
|
||||
interactionID := r.PathValue("InteractionID")
|
||||
router.assert.NotZero(interactionID, "This method should be used after the path values are checked")
|
||||
|
||||
// TODO: Methods to manipulate interactions, instead of router need to do this logic
|
||||
page, err := router.service.GetPage(id, pageID)
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
page.Image = nil // HACK: Prevent image update on S3
|
||||
|
||||
delete(page.Interactions, interactionID)
|
||||
|
||||
err = router.service.UpdatePage(id, page)
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/projects/%s/", id), http.StatusSeeOther)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"forge.capytal.company/capytalcode/project-comicverse/service"
|
||||
"forge.capytal.company/loreddev/x/smalltrip/exception"
|
||||
)
|
||||
|
||||
func (router *router) projects(w http.ResponseWriter, r *http.Request) {
|
||||
router.assert.NotNil(w)
|
||||
router.assert.NotNil(r)
|
||||
|
||||
switch getMethod(r) {
|
||||
case http.MethodGet, http.MethodHead:
|
||||
if id := r.PathValue("ID"); id != "" {
|
||||
router.getProject(w, r)
|
||||
} else {
|
||||
router.listProjects(w, r)
|
||||
}
|
||||
|
||||
case http.MethodPost:
|
||||
router.createProject(w, r)
|
||||
|
||||
case http.MethodDelete:
|
||||
if id := r.PathValue("ID"); id != "" {
|
||||
router.deleteProject(w, r)
|
||||
} else {
|
||||
exception.
|
||||
BadRequest(errors.New(`missing "ID" path value`)).
|
||||
ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
default:
|
||||
exception.MethodNotAllowed([]string{
|
||||
http.MethodHead,
|
||||
http.MethodGet,
|
||||
http.MethodPost,
|
||||
http.MethodDelete,
|
||||
}).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (router *router) createProject(w http.ResponseWriter, r *http.Request) {
|
||||
router.assert.NotNil(w)
|
||||
router.assert.NotNil(r)
|
||||
router.assert.NotNil(router.service)
|
||||
|
||||
if getMethod(r) != http.MethodPost {
|
||||
exception.
|
||||
MethodNotAllowed([]string{http.MethodPost}).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
p, err := router.service.CreateProject()
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
router.assert.NotZero(p.ID)
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/", path.Join(r.URL.Path, p.ID)), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (router *router) getProject(w http.ResponseWriter, r *http.Request) {
|
||||
router.assert.NotNil(w)
|
||||
router.assert.NotNil(r)
|
||||
router.assert.NotNil(router.service)
|
||||
router.assert.NotNil(router.templates)
|
||||
|
||||
if getMethod(r) != http.MethodGet && getMethod(r) != http.MethodHead {
|
||||
exception.
|
||||
MethodNotAllowed([]string{http.MethodGet, http.MethodHead}).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("ID")
|
||||
if id == "" {
|
||||
exception.
|
||||
BadRequest(fmt.Errorf(`a valid path value of "ID" must be provided`)).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
p, err := router.service.GetProject(id)
|
||||
switch {
|
||||
case errors.Is(err, service.ErrProjectNotExists):
|
||||
exception.NotFound().ServeHTTP(w, r)
|
||||
return
|
||||
|
||||
case err != nil:
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err = router.templates.ExecuteTemplate(w, "project", p)
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (router *router) listProjects(w http.ResponseWriter, r *http.Request) {
|
||||
router.assert.NotNil(w)
|
||||
router.assert.NotNil(r)
|
||||
router.assert.NotNil(router.service)
|
||||
router.assert.NotNil(router.templates)
|
||||
|
||||
if getMethod(r) != http.MethodGet && getMethod(r) != http.MethodHead {
|
||||
exception.
|
||||
MethodNotAllowed([]string{http.MethodGet, http.MethodHead}).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ps, err := router.service.ListProjects()
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
b, err := json.Marshal(ps)
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err = w.Write(b)
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (router *router) deleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
router.assert.NotNil(w)
|
||||
router.assert.NotNil(r)
|
||||
router.assert.NotNil(router.service)
|
||||
router.assert.NotNil(router.templates)
|
||||
|
||||
if getMethod(r) != http.MethodDelete {
|
||||
exception.
|
||||
MethodNotAllowed([]string{http.MethodDelete}).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("ID")
|
||||
if id == "" {
|
||||
exception.
|
||||
BadRequest(fmt.Errorf(`a valid path value of "ID" must be provided`)).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err := router.service.DeleteProject(id)
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err = router.templates.ExecuteTemplate(w, "partials-status", map[string]any{
|
||||
"StatusCode": http.StatusOK,
|
||||
"Message": fmt.Sprintf("Project %q successfully deleted", id),
|
||||
"Redirect": "/dashboard/",
|
||||
"RedirectMessage": "Go back to dashboard",
|
||||
})
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"forge.capytal.company/capytalcode/project-comicverse/service"
|
||||
"forge.capytal.company/capytalcode/project-comicverse/templates"
|
||||
@@ -16,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
type router struct {
|
||||
service *service.Service
|
||||
userService *service.UserService
|
||||
|
||||
templates templates.ITemplate
|
||||
assets fs.FS
|
||||
@@ -27,8 +26,8 @@ type router struct {
|
||||
}
|
||||
|
||||
func New(cfg Config) (http.Handler, error) {
|
||||
if cfg.Service == nil {
|
||||
return nil, errors.New("service is nil")
|
||||
if cfg.UserService == nil {
|
||||
return nil, errors.New("user service is nil")
|
||||
}
|
||||
if cfg.Templates == nil {
|
||||
return nil, errors.New("templates is nil")
|
||||
@@ -44,7 +43,7 @@ func New(cfg Config) (http.Handler, error) {
|
||||
}
|
||||
|
||||
r := &router{
|
||||
service: cfg.Service,
|
||||
userService: cfg.UserService,
|
||||
|
||||
templates: cfg.Templates,
|
||||
assets: cfg.Assets,
|
||||
@@ -58,7 +57,7 @@ func New(cfg Config) (http.Handler, error) {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Service *service.Service
|
||||
UserService *service.UserService
|
||||
|
||||
Templates templates.ITemplate
|
||||
Assets fs.FS
|
||||
@@ -91,48 +90,29 @@ func (router *router) setup() http.Handler {
|
||||
r.Use(exception.PanicMiddleware())
|
||||
r.Use(exception.Middleware())
|
||||
|
||||
userController := newUserController(router.userService, router.templates, router.assert)
|
||||
|
||||
r.Handle("/assets/", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
|
||||
|
||||
r.HandleFunc("/dashboard/", router.dashboard)
|
||||
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
|
||||
}
|
||||
|
||||
r.HandleFunc("/projects/{$}", router.projects)
|
||||
r.HandleFunc("/projects/{ID}/", router.projects)
|
||||
r.HandleFunc("/projects/{ID}/pages/{$}", router.pages)
|
||||
r.HandleFunc("/projects/{ID}/pages/{PageID}", router.pages)
|
||||
r.HandleFunc("/projects/{ID}/pages/{PageID}/interactions/{$}", router.interactions)
|
||||
r.HandleFunc("/projects/{ID}/pages/{PageID}/interactions/{InteractionID}", router.interactions)
|
||||
err := router.templates.ExecuteTemplate(w, "landing", nil)
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
r.HandleFunc("/login/{$}", userController.login)
|
||||
r.HandleFunc("/register/{$}", userController.register)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (router *router) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
router.assert.NotNil(router.templates)
|
||||
router.assert.NotNil(router.service)
|
||||
router.assert.NotNil(w)
|
||||
router.assert.NotNil(r)
|
||||
|
||||
p, err := router.service.ListProjects()
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
err = router.templates.ExecuteTemplate(w, "dashboard", p)
|
||||
if err != nil {
|
||||
exception.InternalServerError(err).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func getMethod(r *http.Request) string {
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
return r.Method
|
||||
}
|
||||
|
||||
m := r.FormValue("x-method")
|
||||
if m == "" {
|
||||
return r.Method
|
||||
}
|
||||
|
||||
return strings.ToUpper(m)
|
||||
}
|
||||
|
||||
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,180 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"forge.capytal.company/capytalcode/project-comicverse/internals/randstr"
|
||||
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
const pageIDLength = 6
|
||||
|
||||
var ErrPageNotExists = errors.New("page does not exists in storage")
|
||||
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Pages []ProjectPage `json:"pages"`
|
||||
}
|
||||
|
||||
type ProjectPage struct {
|
||||
ID string `json:"id"`
|
||||
Interactions map[string]PageInteraction `json:"interactions"`
|
||||
Image io.ReadCloser `json:"-"`
|
||||
}
|
||||
|
||||
type PageInteraction struct {
|
||||
URL string `json:"url"`
|
||||
X uint16 `json:"x"`
|
||||
Y uint16 `json:"y"`
|
||||
}
|
||||
|
||||
func (s *Service) AddPage(projectID string, img io.Reader) error {
|
||||
s.assert.NotNil(s.ctx)
|
||||
s.assert.NotNil(s.s3)
|
||||
s.assert.NotNil(s.bucket)
|
||||
s.assert.NotZero(projectID)
|
||||
s.assert.NotNil(img)
|
||||
|
||||
id, err := randstr.NewHex(pageIDLength)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p, err := s.GetProject(projectID)
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("unable to get project"), err)
|
||||
}
|
||||
|
||||
p.Pages = append(p.Pages, ProjectPage{ID: id, Interactions: map[string]PageInteraction{}})
|
||||
|
||||
k := fmt.Sprintf("%s/%s", projectID, id)
|
||||
_, err = s.s3.PutObject(s.ctx, &s3.PutObjectInput{
|
||||
Key: &k,
|
||||
Body: img,
|
||||
Bucket: &s.bucket,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.UpdateProject(projectID, p)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) GetPage(projectID string, pageID string) (ProjectPage, error) {
|
||||
s.assert.NotNil(s.ctx)
|
||||
s.assert.NotNil(s.s3)
|
||||
s.assert.NotNil(s.bucket)
|
||||
s.assert.NotZero(projectID)
|
||||
s.assert.NotNil(pageID)
|
||||
|
||||
p, err := s.GetProject(projectID)
|
||||
if err != nil {
|
||||
return ProjectPage{}, errors.Join(errors.New("unable to get project"), err)
|
||||
}
|
||||
|
||||
pageIndex := slices.IndexFunc(p.Pages, func(p ProjectPage) bool { return p.ID == pageID })
|
||||
if pageIndex == -1 {
|
||||
return ProjectPage{}, ErrPageNotExists
|
||||
}
|
||||
page := p.Pages[pageIndex]
|
||||
|
||||
k := fmt.Sprintf("%s/%s", projectID, pageID)
|
||||
res, err := s.s3.GetObject(s.ctx, &s3.GetObjectInput{
|
||||
Key: &k,
|
||||
Bucket: &s.bucket,
|
||||
})
|
||||
if err != nil {
|
||||
var resErr *awshttp.ResponseError
|
||||
if errors.As(err, &resErr) && resErr.ResponseError.HTTPStatusCode() == http.StatusNotFound {
|
||||
// TODO: This would probably be better in some background "maintenance" worker
|
||||
p.Pages = slices.Delete(p.Pages, pageIndex, pageIndex)
|
||||
_ = s.UpdateProject(projectID, p)
|
||||
|
||||
return ProjectPage{}, errors.Join(ErrPageNotExists, resErr)
|
||||
}
|
||||
return ProjectPage{}, err
|
||||
}
|
||||
|
||||
s.assert.NotNil(res.Body)
|
||||
s.assert.NotNil(page.Interactions)
|
||||
|
||||
page.Image = res.Body
|
||||
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdatePage(projectID string, page ProjectPage) error {
|
||||
s.assert.NotNil(s.ctx)
|
||||
s.assert.NotNil(s.s3)
|
||||
s.assert.NotNil(s.bucket)
|
||||
s.assert.NotZero(projectID)
|
||||
s.assert.NotZero(page.ID)
|
||||
s.assert.NotNil(page.Interactions)
|
||||
|
||||
p, err := s.GetProject(projectID)
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("unable to get project"), err)
|
||||
}
|
||||
|
||||
pageIndex := slices.IndexFunc(p.Pages, func(p ProjectPage) bool { return p.ID == page.ID })
|
||||
if pageIndex == -1 {
|
||||
return ErrPageNotExists
|
||||
}
|
||||
p.Pages[pageIndex] = page
|
||||
|
||||
// TODO: Probably a "lastUpdated" timestamp in the ProjectPage data would be better
|
||||
// so we don't update equal images. Changing the image in ProjectPage would be better
|
||||
// using a method, or could be completely decoupled from the struct.
|
||||
if page.Image != nil {
|
||||
k := fmt.Sprintf("%s/%s", projectID, page.ID)
|
||||
_, err = s.s3.PutObject(s.ctx, &s3.PutObjectInput{
|
||||
Key: &k,
|
||||
Body: page.Image,
|
||||
Bucket: &s.bucket,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("error while trying to update image"), err)
|
||||
}
|
||||
}
|
||||
|
||||
err = s.UpdateProject(projectID, p)
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("error while trying to update project"), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) DeletePage(projectID string, id string) error {
|
||||
s.assert.NotNil(s.ctx)
|
||||
s.assert.NotNil(s.s3)
|
||||
s.assert.NotNil(s.bucket)
|
||||
s.assert.NotZero(projectID)
|
||||
s.assert.NotNil(id)
|
||||
|
||||
p, err := s.GetProject(projectID)
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("unable to get project"), err)
|
||||
}
|
||||
|
||||
k := fmt.Sprintf("%s/%s", projectID, id)
|
||||
_, err = s.s3.DeleteObject(s.ctx, &s3.DeleteObjectInput{
|
||||
Key: &k,
|
||||
Bucket: &s.bucket,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.Pages = slices.DeleteFunc(p.Pages, func(p ProjectPage) bool { return p.ID == id })
|
||||
|
||||
err = s.UpdateProject(projectID, p)
|
||||
return err
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
|
||||
"forge.capytal.company/capytalcode/project-comicverse/database"
|
||||
"forge.capytal.company/capytalcode/project-comicverse/internals/randstr"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
)
|
||||
|
||||
const projectIDLength = 6
|
||||
|
||||
var ErrProjectNotExists = errors.New("project does not exists in database")
|
||||
|
||||
func (s *Service) CreateProject() (Project, error) {
|
||||
s.assert.NotNil(s.db)
|
||||
s.assert.NotNil(s.s3)
|
||||
s.assert.NotNil(s.ctx)
|
||||
s.assert.NotZero(s.bucket)
|
||||
|
||||
s.log.Debug("Creating new project")
|
||||
|
||||
id, err := randstr.NewHex(projectIDLength)
|
||||
if err != nil {
|
||||
return Project{}, errors.Join(errors.New("creating hexadecimal ID returned error"), err)
|
||||
}
|
||||
|
||||
title := "New Project"
|
||||
|
||||
s.assert.NotZero(id, "ID should never be empty")
|
||||
|
||||
s.log.Debug("Creating project on database", slog.String("id", id))
|
||||
|
||||
_, err = s.db.CreateProject(id, title)
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
p := Project{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Pages: []ProjectPage{},
|
||||
}
|
||||
|
||||
c, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
s.log.Debug("Creating project on storage", slog.String("id", id))
|
||||
|
||||
f := fmt.Sprintf("%s.comic.json", id)
|
||||
_, err = s.s3.PutObject(s.ctx, &s3.PutObjectInput{
|
||||
Bucket: &s.bucket,
|
||||
Key: &f,
|
||||
Body: bytes.NewReader(c),
|
||||
})
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetProject(id string) (Project, error) {
|
||||
s.assert.NotNil(s.db)
|
||||
s.assert.NotNil(s.s3)
|
||||
s.assert.NotZero(s.bucket)
|
||||
s.assert.NotNil(s.ctx)
|
||||
s.assert.NotZero(id)
|
||||
|
||||
res, err := s.db.GetProject(id)
|
||||
if errors.Is(err, database.ErrNoRows) {
|
||||
return Project{}, errors.Join(ErrProjectNotExists, err)
|
||||
}
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
f := fmt.Sprintf("%s.comic.json", id)
|
||||
file, err := s.s3.GetObject(s.ctx, &s3.GetObjectInput{
|
||||
Bucket: &s.bucket,
|
||||
Key: &f,
|
||||
})
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
c, err := io.ReadAll(file.Body)
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
var p Project
|
||||
err = json.Unmarshal(c, &p)
|
||||
|
||||
s.assert.Equal(res.ID, p.ID, "The project ID should always be equal in the Database and Storage")
|
||||
s.assert.Equal(res.Title, p.Title)
|
||||
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (s *Service) ListProjects() ([]Project, error) {
|
||||
s.assert.NotNil(s.db)
|
||||
|
||||
ps, err := s.db.ListProjects()
|
||||
if err != nil {
|
||||
return []Project{}, err
|
||||
}
|
||||
|
||||
p := make([]Project, len(ps))
|
||||
for i, dp := range ps {
|
||||
// TODO: this is temporally for debugging, getting every project
|
||||
// from s3 can be expensive
|
||||
v, err := s.GetProject(dp.ID)
|
||||
if err != nil {
|
||||
return []Project{}, err
|
||||
}
|
||||
p[i] = v
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateProject(id string, project Project) error {
|
||||
s.assert.NotNil(s.db)
|
||||
s.assert.NotNil(s.s3)
|
||||
s.assert.NotZero(s.bucket)
|
||||
s.assert.NotNil(s.ctx)
|
||||
s.assert.NotZero(id)
|
||||
|
||||
c, err := json.Marshal(project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.log.Debug("Updating project on storage", slog.String("id", id))
|
||||
|
||||
f := fmt.Sprintf("%s.comic.json", id)
|
||||
_, err = s.s3.PutObject(s.ctx, &s3.PutObjectInput{
|
||||
Bucket: &s.bucket,
|
||||
Body: bytes.NewReader(c),
|
||||
Key: &f,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteProject(id string) error {
|
||||
s.assert.NotNil(s.db)
|
||||
s.assert.NotNil(s.s3)
|
||||
s.assert.NotZero(s.bucket)
|
||||
s.assert.NotNil(s.ctx)
|
||||
s.assert.NotZero(id)
|
||||
|
||||
p, err := s.GetProject(id)
|
||||
if err != nil {
|
||||
return errors.Join(errors.New("unable to get information of project"), err)
|
||||
}
|
||||
|
||||
err = s.db.DeleteProject(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.log.Debug("Deleting project on storage", slog.String("id", id))
|
||||
|
||||
files := []types.ObjectIdentifier{}
|
||||
|
||||
f := fmt.Sprintf("%s.comic.json", id)
|
||||
files = append(files, types.ObjectIdentifier{Key: &f})
|
||||
|
||||
for k := range p.Pages {
|
||||
f := fmt.Sprintf("%s/%s", id, k)
|
||||
files = append(files, types.ObjectIdentifier{Key: &f})
|
||||
}
|
||||
|
||||
_, err = s.s3.DeleteObjects(s.ctx, &s3.DeleteObjectsInput{
|
||||
Delete: &types.Delete{Objects: files},
|
||||
Bucket: &s.bucket,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,64 +1 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"forge.capytal.company/capytalcode/project-comicverse/database"
|
||||
"forge.capytal.company/loreddev/x/tinyssert"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *database.Database
|
||||
s3 *s3.Client
|
||||
bucket string
|
||||
|
||||
ctx context.Context
|
||||
|
||||
assert tinyssert.Assertions
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func New(cfg Config) (*Service, error) {
|
||||
if cfg.DB == nil {
|
||||
return nil, errors.New("database should not be a nil pointer")
|
||||
}
|
||||
if cfg.S3 == nil {
|
||||
return nil, errors.New("s3 client should not be a nil pointer")
|
||||
}
|
||||
if cfg.Bucket == "" {
|
||||
return nil, errors.New("bucket should not be a empty string")
|
||||
}
|
||||
if cfg.Context == nil {
|
||||
return nil, errors.New("context should not be a nil interface")
|
||||
}
|
||||
if cfg.Assertions == nil {
|
||||
return nil, errors.New("assertions should not be a nil interface")
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
return nil, errors.New("logger should not be a nil pointer")
|
||||
}
|
||||
return &Service{
|
||||
db: cfg.DB,
|
||||
s3: cfg.S3,
|
||||
bucket: cfg.Bucket,
|
||||
|
||||
ctx: cfg.Context,
|
||||
|
||||
assert: cfg.Assertions,
|
||||
log: cfg.Logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
DB *database.Database
|
||||
S3 *s3.Client
|
||||
Bucket string
|
||||
|
||||
Context context.Context
|
||||
|
||||
Assertions tinyssert.Assertions
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
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}}
|
||||
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}}
|
||||
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}}
|
||||
2
x
2
x
Submodule x updated: ceda7536f1...c62be87c6a
Reference in New Issue
Block a user