diff --git a/api/invite.go b/api/invite.go new file mode 100644 index 0000000..004b7b1 --- /dev/null +++ b/api/invite.go @@ -0,0 +1,54 @@ +package api + +import ( + "strconv" + "time" + + "go.jolheiser.com/invitea/database" +) + +type Invite struct { + ID int64 + Code string + Uses int64 + Total int64 + Expiration time.Time +} + +func (i *Invite) TotalString() string { + if i.Total == 0 { + return "∞" + } + return strconv.FormatInt(i.Total, 10) +} + +func (i *Invite) ExpirationString() string { + if i.Expiration.Unix() == 0 { + return "never" + } + return i.Expiration.Format("01/02/2006") +} + +func (i *Invite) Valid() bool { + limited := i.Total != 0 && i.Uses >= i.Total + expired := i.Expiration.Unix() != 0 && time.Now().After(i.Expiration) + return !(limited || expired) +} + +func InviteFromDB(i database.Invite) Invite { + return Invite{ + ID: i.ID, + Code: i.Code, + Uses: i.Uses, + Total: i.Total.Int64, + Expiration: time.Unix(i.Expiration.Int64, 0).UTC(), + } +} + +func InvitesFromDB(i ...database.Invite) []Invite { + invites := make([]Invite, 0, len(i)) + for _, ii := range i { + invites = append(invites, InviteFromDB(ii)) + } + return invites +} diff --git a/database/invites.sql.go b/database/invites.sql.go index 26f02b7..10c08dd 100644 --- a/database/invites.sql.go +++ b/database/invites.sql.go @@ -7,8 +7,7 @@ package database import ( "context" - - "go.jolheiser.com/invitea/database/sqlc" + "database/sql" ) const countInvites = `-- name: CountInvites :one @@ -34,8 +33,8 @@ RETURNING id, code, uses, total, expiration type CreateInviteParams struct { Code string Uses int64 - Total int64 - Expiration sqlc.Timestamp + Total sql.NullInt64 + Expiration sql.NullInt64 } func (q *Queries) CreateInvite(ctx context.Context, arg CreateInviteParams) (Invite, error) { @@ -68,11 +67,11 @@ func (q *Queries) DeleteInvite(ctx context.Context, id int64) error { const getInvite = `-- name: GetInvite :one SELECT id, code, uses, total, expiration FROM invites -WHERE id = ? LIMIT 1 +WHERE code = ? LIMIT 1 ` -func (q *Queries) GetInvite(ctx context.Context, id int64) (Invite, error) { - row := q.db.QueryRowContext(ctx, getInvite, id) +func (q *Queries) GetInvite(ctx context.Context, code string) (Invite, error) { + row := q.db.QueryRowContext(ctx, getInvite, code) var i Invite err := row.Scan( &i.ID, @@ -86,7 +85,7 @@ func (q *Queries) GetInvite(ctx context.Context, id int64) (Invite, error) { const listInvites = `-- name: ListInvites :many SELECT id, code, uses, total, expiration FROM invites -ORDER BY id +ORDER BY id DESC ` func (q *Queries) ListInvites(ctx context.Context) ([]Invite, error) { @@ -117,3 +116,19 @@ func (q *Queries) ListInvites(ctx context.Context) ([]Invite, error) { } return items, nil } + +const updateInvite = `-- name: UpdateInvite :exec +UPDATE invites +SET uses = ? +WHERE id = ? +` + +type UpdateInviteParams struct { + Uses int64 + ID int64 +} + +func (q *Queries) UpdateInvite(ctx context.Context, arg UpdateInviteParams) error { + _, err := q.db.ExecContext(ctx, updateInvite, arg.Uses, arg.ID) + return err +} diff --git a/database/models.go b/database/models.go index 514f0bd..e10298b 100644 --- a/database/models.go +++ b/database/models.go @@ -5,13 +5,13 @@ package database import ( - "go.jolheiser.com/invitea/database/sqlc" + "database/sql" ) type Invite struct { ID int64 Code string Uses int64 - Total int64 - Expiration sqlc.Timestamp + Total sql.NullInt64 + Expiration sql.NullInt64 } diff --git a/database/sqlc/migrations/001_schema.up.sql b/database/sqlc/migrations/001_schema.up.sql index ec74e62..542a2ad 100644 --- a/database/sqlc/migrations/001_schema.up.sql +++ b/database/sqlc/migrations/001_schema.up.sql @@ -2,6 +2,6 @@ CREATE TABLE invites ( id INTEGER PRIMARY KEY, code TEXT NOT NULL, uses INTEGER NOT NULL, - total INTEGER NOT NULL, - expiration DATE NOT NULL + total INTEGER, + expiration INTEGER ); \ No newline at end of file diff --git a/database/sqlc/queries/invites.sql b/database/sqlc/queries/invites.sql index 7c235ce..c085890 100644 --- a/database/sqlc/queries/invites.sql +++ b/database/sqlc/queries/invites.sql @@ -1,10 +1,10 @@ -- name: GetInvite :one SELECT * FROM invites -WHERE id = ? LIMIT 1; +WHERE code = ? LIMIT 1; -- name: ListInvites :many SELECT * FROM invites -ORDER BY id; +ORDER BY id DESC; -- name: CreateInvite :one INSERT INTO invites ( @@ -14,6 +14,11 @@ INSERT INTO invites ( ) RETURNING *; +-- name: UpdateInvite :exec +UPDATE invites +SET uses = ? +WHERE id = ?; + -- name: DeleteInvite :exec DELETE FROM invites WHERE id = ?; diff --git a/database/sqlc/sqlc.yaml b/database/sqlc/sqlc.yaml index 6fb5ea8..25c1864 100644 --- a/database/sqlc/sqlc.yaml +++ b/database/sqlc/sqlc.yaml @@ -6,7 +6,4 @@ sql: gen: go: package: "database" - out: "../" - overrides: - - db_type: "DATE" - go_type: "go.jolheiser.com/invitea/database/sqlc.Timestamp" \ No newline at end of file + out: "../" \ No newline at end of file diff --git a/main.go b/main.go index d40d097..a9e8c2c 100644 --- a/main.go +++ b/main.go @@ -4,15 +4,17 @@ import ( "database/sql" "errors" "fmt" - "github.com/golang-migrate/migrate/v4" "net/http" "os" "os/signal" + "strings" "go.jolheiser.com/invitea/database" "go.jolheiser.com/invitea/database/sqlc/migrations" "go.jolheiser.com/invitea/router" + "code.gitea.io/sdk/gitea" + "github.com/golang-migrate/migrate/v4" "github.com/peterbourgon/ff/v3" "github.com/peterbourgon/ff/v3/ffyaml" "github.com/rs/zerolog" @@ -63,11 +65,17 @@ func main() { log.Fatal().Err(err).Msg("could not migrate database") } + client, err := gitea.NewClient(*giteaURLFlag, gitea.SetToken(*giteaTokenFlag)) + if err != nil { + log.Fatal().Err(err).Msg("could not create gitea client") + } + r := router.New(router.Config{ Domain: *domainFlag, SessionSecret: *sessionSecretFlag, Database: database.New(db), - GiteaURL: *giteaURLFlag, + GiteaClient: client, + GiteaURL: strings.TrimSuffix(*giteaURLFlag, "/"), GiteaClientKey: *giteaClientKeyFlag, GiteaClientSecret: *giteaClientSecretFlag, GiteaToken: *giteaTokenFlag, diff --git a/router/router.go b/router/router.go index 52822bd..6506589 100644 --- a/router/router.go +++ b/router/router.go @@ -8,6 +8,7 @@ import ( "go.jolheiser.com/invitea/database" "go.jolheiser.com/invitea/static" + sdk "code.gitea.io/sdk/gitea" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/gorilla/sessions" @@ -21,6 +22,7 @@ type Config struct { Domain string SessionSecret string Database *database.Queries + GiteaClient *sdk.Client GiteaURL string GiteaClientKey string @@ -50,7 +52,9 @@ func New(cfg Config) *chi.Mux { store := NewSessionStore(cfg.SessionSecret, cfg.GiteaURL) routes := Routes{ - DB: cfg.Database, + DB: cfg.Database, + Gitea: cfg.GiteaClient, + GiteaURL: cfg.GiteaURL, } mux := chi.NewMux() @@ -67,6 +71,12 @@ func New(cfg Config) *chi.Mux { mux.Route("/", func(r chi.Router) { r.Use(store.Middleware) r.Get("/", routes.Index) + r.With(store.RequireAuth).Post("/", routes.IndexPost) + }) + + mux.Route("/invite/{code}", func(r chi.Router) { + r.Get("/", routes.Invite) + r.Post("/", routes.InvitePost) }) mux.Route("/auth", func(r chi.Router) { @@ -83,6 +93,17 @@ func New(cfg Config) *chi.Mux { } http.Redirect(w, r, cfg.Domain, http.StatusFound) }) + r.Get("/logout", func(w http.ResponseWriter, r *http.Request) { + if err := gothic.Logout(w, r); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := store.Logout(w, r); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, cfg.Domain, http.StatusFound) + }) r.Get("/callback", func(w http.ResponseWriter, r *http.Request) { user, err := gothic.CompleteUserAuth(w, r) if err != nil { diff --git a/router/routes.go b/router/routes.go index f90f867..4e5fbe8 100644 --- a/router/routes.go +++ b/router/routes.go @@ -1,16 +1,26 @@ package router import ( + "crypto/rand" + "database/sql" + "encoding/hex" "net/http" + "strconv" + "time" + "go.jolheiser.com/invitea/api" "go.jolheiser.com/invitea/database" "go.jolheiser.com/invitea/static" + "code.gitea.io/sdk/gitea" + "github.com/go-chi/chi/v5" "github.com/rs/zerolog/log" ) type Routes struct { - DB *database.Queries + DB *database.Queries + Gitea *gitea.Client + GiteaURL string } func (ro *Routes) Index(w http.ResponseWriter, r *http.Request) { @@ -19,15 +29,151 @@ func (ro *Routes) Index(w http.ResponseWriter, r *http.Request) { isAdmin = ia } - invites, err := ro.DB.ListInvites(r.Context()) + dbInvites, err := ro.DB.ListInvites(r.Context()) if err != nil { log.Err(err).Msg("") } if err := static.Templates.ExecuteTemplate(w, "index.tmpl", map[string]any{ - "isAdmin": isAdmin, - "invites": invites, + "isAdmin": isAdmin, + "username": r.Context().Value("username"), + "invites": api.InvitesFromDB(dbInvites...), }); err != nil { log.Err(err).Msg("") } } + +func (ro *Routes) IndexPost(w http.ResponseWriter, r *http.Request) { + action := r.FormValue("action") + if action == "" { + http.Redirect(w, r, "/", http.StatusBadRequest) + return + } + + switch action { + case "delete": + id, err := strconv.ParseInt(r.FormValue("id"), 10, 64) + if err != nil { + http.Error(w, "invalid ID", http.StatusBadRequest) + return + } + if err := ro.DB.DeleteInvite(r.Context(), id); err != nil { + log.Err(err).Msg("could not delete invite") + http.Error(w, "could not delete invite", http.StatusInternalServerError) + return + } + case "create": + u := r.FormValue("uses") + var uses int64 + if u != "" { + var err error + if uses, err = strconv.ParseInt(u, 10, 64); err != nil { + http.Error(w, "invalid uses", http.StatusBadRequest) + return + } + } + + e := r.FormValue("expiration") + var expiration int64 + if e != "" { + ex, err := time.Parse("2006-01-02", e) + if err != nil { + http.Error(w, "invalid expiration", http.StatusBadRequest) + return + } + expiration = ex.Unix() + } + + code := make([]byte, 5) + if _, err := rand.Read(code); err != nil { + log.Err(err).Msg("could not generate code") + http.Error(w, "could not generate code", http.StatusInternalServerError) + return + } + + if _, err := ro.DB.CreateInvite(r.Context(), database.CreateInviteParams{ + Code: hex.EncodeToString(code), + Total: sql.NullInt64{ + Int64: uses, + Valid: true, + }, + Expiration: sql.NullInt64{ + Int64: expiration, + Valid: true, + }, + }); err != nil { + log.Err(err).Msg("could not create invite") + http.Error(w, "could not create invite", http.StatusInternalServerError) + return + } + } + + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (ro *Routes) Invite(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + + dbInvite, err := ro.DB.GetInvite(r.Context(), code) + if err != nil { + log.Err(err).Str("code", code).Msg("could not get invite") + http.NotFound(w, r) + return + } + + invite := api.InviteFromDB(dbInvite) + if !invite.Valid() { + http.NotFound(w, r) + return + } + + if err := static.Templates.ExecuteTemplate(w, "invite.tmpl", map[string]any{ + "invite": invite, + }); err != nil { + log.Err(err).Msg("") + } +} + +func (ro *Routes) InvitePost(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + + dbInvite, err := ro.DB.GetInvite(r.Context(), code) + if err != nil { + log.Err(err).Msg(code) + http.NotFound(w, r) + return + } + + invite := api.InviteFromDB(dbInvite) + if !invite.Valid() { + http.NotFound(w, r) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + email := r.FormValue("email") + + var b bool + if _, _, err := ro.Gitea.AdminCreateUser(gitea.CreateUserOption{ + Username: username, + Email: email, + Password: password, + MustChangePassword: &b, + }); err != nil { + log.Err(err).Msg("could not create user") + http.Error(w, "could not create user", http.StatusInternalServerError) + return + } + + if err := ro.DB.UpdateInvite(r.Context(), database.UpdateInviteParams{ + ID: dbInvite.ID, + Uses: dbInvite.Uses + 1, + }); err != nil { + log.Err(err).Msg("could not update invite") + http.Error(w, "could not update invite", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, ro.GiteaURL+"/user/login", http.StatusSeeOther) +} diff --git a/router/session.go b/router/session.go index 01e3ac9..6a458b5 100644 --- a/router/session.go +++ b/router/session.go @@ -25,6 +25,7 @@ func (s *SessionStore) Middleware(next http.Handler) http.Handler { } r = r.WithContext(context.WithValue(r.Context(), "isAdmin", sess.Values["isAdmin"])) + r = r.WithContext(context.WithValue(r.Context(), "username", sess.Values["username"])) next.ServeHTTP(w, r) }) } @@ -62,11 +63,22 @@ func (s *SessionStore) Auth(w http.ResponseWriter, r *http.Request, token string } sess.Values["authenticated"] = true sess.Values["isAdmin"] = profile.IsAdmin + sess.Values["username"] = profile.UserName + return s.Store.Save(r, w, sess) +} + +func (s *SessionStore) Logout(w http.ResponseWriter, r *http.Request) error { + sess, err := s.Store.Get(r, sessionCookie) + if err != nil { + return err + } + sess.Options.MaxAge = -1 return s.Store.Save(r, w, sess) } func NewSessionStore(sessionSecret, giteURL string) *SessionStore { store := sessions.NewCookieStore([]byte(sessionSecret)) + store.MaxAge(0) return &SessionStore{ Store: store, GiteaURL: giteURL, diff --git a/static/templates/admin.tmpl b/static/templates/admin.tmpl index d77af0f..0cb0105 100644 --- a/static/templates/admin.tmpl +++ b/static/templates/admin.tmpl @@ -2,7 +2,7 @@

- +

@@ -24,10 +24,22 @@ {{ range .invites }} - {{ .Code }} - {{ .Uses }}/{{ .Total }} - {{ .Expiration }} - Delete + + {{ if .Valid -}} + {{ .Code }} + {{- else -}} + {{ .Code }} + {{- end }} + + {{ .Uses }} / {{ .TotalString }} + {{ .ExpirationString }} + +

+ + + +
+ {{ end }} diff --git a/static/templates/index.tmpl b/static/templates/index.tmpl index cec60ae..6bf2270 100644 --- a/static/templates/index.tmpl +++ b/static/templates/index.tmpl @@ -12,6 +12,9 @@
{{ if .isAdmin }} +

+ Hello, {{ .username }}. Log Out +

{{ template "admin.tmpl" . }} {{ else }}

diff --git a/static/templates/invite.tmpl b/static/templates/invite.tmpl new file mode 100644 index 0000000..e234431 --- /dev/null +++ b/static/templates/invite.tmpl @@ -0,0 +1,50 @@ + + + Invitea + + + +

+

Invitea

+

+ Invite Code: {{ .invite.Code }} +

+
+
+
+

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ + +
+
+ + + \ No newline at end of file