36 Commits

Author SHA1 Message Date
41a764939b feat: landing page template 2025-06-09 19:21:29 -03:00
dc61ed91d0 feat: update assertions contructor function call 2025-06-09 19:21:19 -03:00
3f767299e2 chore: new debugger make job 2025-06-09 19:20:46 -03:00
d5f13b563e chore: ignore air's tmp directory 2025-06-09 19:20:31 -03:00
7308097c61 chore(service): delete old all-service service 2025-06-09 19:20:06 -03:00
e3ce651288 refactor(router): rename c receiver to ctrl 2025-06-09 19:19:27 -03:00
0e7198f918 feat(router): set session token cookie 2025-06-09 19:19:00 -03:00
f4a971bdae feat(router): show landing page if user is not logged in 2025-06-09 19:18:29 -03:00
8403459cc8 fix(repo): send error value on user insert query exec 2025-06-09 19:17:26 -03:00
4e90fa0063 refactor(repo): rename repositoryDateFormat to dateFormat 2025-06-09 19:16:40 -03:00
f622f774e4 refactor: move shared variables to repository.go file 2025-06-09 19:14:28 -03:00
29f1e8cc8a chore: use fortify for hardeningDisable 2025-06-06 16:36:12 -03:00
0cea250fa4 chore: set environment variables on direnv enter 2025-06-06 16:35:53 -03:00
c3a0be5ec5 feat(templates): register page and form 2025-06-06 16:35:23 -03:00
72b884c2b3 feat(templates): login page and form 2025-06-06 16:35:16 -03:00
d38097a616 feat(router,users): register method for creating a new user 2025-06-06 16:34:59 -03:00
f7396dc12b feat(router,users): return token cookie on login 2025-06-06 16:34:41 -03:00
149823a5fc fix(router,users): correct username form value name on error 2025-06-06 16:34:16 -03:00
56e2214311 feat(router): handle /login and /register routes 2025-06-06 16:33:29 -03:00
a52caf6580 feat(router): provide UserService to router 2025-06-06 16:32:49 -03:00
30eb1a0065 feat(user,service): return signed token of user 2025-06-06 16:32:02 -03:00
106c612e63 feat(user,service): return error on incorect construct parameter 2025-06-06 16:31:31 -03:00
06807b0623 chore(router,service): remove editor and projects endpoint and services
They will be reimplemented later
2025-06-06 16:30:50 -03:00
12844eafee chore: format launch dev debug profile 2025-06-06 16:29:41 -03:00
d5668af2df fix(repo,users): incorrect syntax for columns in select 2025-06-06 16:29:25 -03:00
4bb32f9757 feat(repo,users): add assertions check for struct values 2025-06-06 16:29:11 -03:00
52ac9ed3bc fix(repo,users): missing context value on struct initiation 2025-06-06 16:28:45 -03:00
2bce92e51c fix(repo,users): trailing comma in create table query 2025-06-06 16:28:11 -03:00
28ed7379de feat: user controller 2025-05-30 18:05:40 -03:00
16322b3afd revert: remove database abstraction 2025-05-30 18:05:24 -03:00
5fbe9cd1ad chore: update submodule 2025-05-30 18:05:01 -03:00
f7f2a7fbb8 chore: update deps 2025-05-30 18:04:46 -03:00
b29bfdd1df feat(templates): login page 2025-05-30 18:04:39 -03:00
deaf9089b2 feat(users): init token service 2025-05-30 18:04:28 -03:00
ffad82b32c feat(users): user service 2025-05-30 18:04:16 -03:00
dbf30a9908 feat(users): user repository 2025-05-30 18:03:56 -03:00
26 changed files with 626 additions and 1241 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ out.css
.tmp
.env
*.db
tmp

8
.vscode/launch.json vendored
View File

@@ -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"]
}
]
}

View File

@@ -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()))

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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
View 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
View 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
View 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
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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("/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)
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()
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
}
w.WriteHeader(http.StatusOK)
err = router.templates.ExecuteTemplate(w, "dashboard", p)
err := router.templates.ExecuteTemplate(w, "landing", nil)
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
}
r.HandleFunc("/login/{$}", userController.login)
r.HandleFunc("/register/{$}", userController.register)
m := r.FormValue("x-method")
if m == "" {
return r.Method
}
return strings.ToUpper(m)
return r
}

139
router/users.go Normal file
View 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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
View 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
View 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
View 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
View 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
View 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

Submodule x updated: ceda7536f1...c62be87c6a