feat: initial version

Signed-off-by: jolheiser <john.olheiser@gmail.com>
main
jolheiser 2023-03-04 00:02:24 -06:00
parent d5717f0e7d
commit d17d17f641
Signed by: jolheiser
GPG Key ID: B853ADA5DA7BBF7A
13 changed files with 354 additions and 31 deletions

54
api/invite.go 100644
View File

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

View File

@ -7,8 +7,7 @@ package database
import ( import (
"context" "context"
"database/sql"
"go.jolheiser.com/invitea/database/sqlc"
) )
const countInvites = `-- name: CountInvites :one const countInvites = `-- name: CountInvites :one
@ -34,8 +33,8 @@ RETURNING id, code, uses, total, expiration
type CreateInviteParams struct { type CreateInviteParams struct {
Code string Code string
Uses int64 Uses int64
Total int64 Total sql.NullInt64
Expiration sqlc.Timestamp Expiration sql.NullInt64
} }
func (q *Queries) CreateInvite(ctx context.Context, arg CreateInviteParams) (Invite, error) { 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 const getInvite = `-- name: GetInvite :one
SELECT id, code, uses, total, expiration FROM invites 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) { func (q *Queries) GetInvite(ctx context.Context, code string) (Invite, error) {
row := q.db.QueryRowContext(ctx, getInvite, id) row := q.db.QueryRowContext(ctx, getInvite, code)
var i Invite var i Invite
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
@ -86,7 +85,7 @@ func (q *Queries) GetInvite(ctx context.Context, id int64) (Invite, error) {
const listInvites = `-- name: ListInvites :many const listInvites = `-- name: ListInvites :many
SELECT id, code, uses, total, expiration FROM invites SELECT id, code, uses, total, expiration FROM invites
ORDER BY id ORDER BY id DESC
` `
func (q *Queries) ListInvites(ctx context.Context) ([]Invite, error) { 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 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
}

View File

@ -5,13 +5,13 @@
package database package database
import ( import (
"go.jolheiser.com/invitea/database/sqlc" "database/sql"
) )
type Invite struct { type Invite struct {
ID int64 ID int64
Code string Code string
Uses int64 Uses int64
Total int64 Total sql.NullInt64
Expiration sqlc.Timestamp Expiration sql.NullInt64
} }

View File

@ -2,6 +2,6 @@ CREATE TABLE invites (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
code TEXT NOT NULL, code TEXT NOT NULL,
uses INTEGER NOT NULL, uses INTEGER NOT NULL,
total INTEGER NOT NULL, total INTEGER,
expiration DATE NOT NULL expiration INTEGER
); );

View File

@ -1,10 +1,10 @@
-- name: GetInvite :one -- name: GetInvite :one
SELECT * FROM invites SELECT * FROM invites
WHERE id = ? LIMIT 1; WHERE code = ? LIMIT 1;
-- name: ListInvites :many -- name: ListInvites :many
SELECT * FROM invites SELECT * FROM invites
ORDER BY id; ORDER BY id DESC;
-- name: CreateInvite :one -- name: CreateInvite :one
INSERT INTO invites ( INSERT INTO invites (
@ -14,6 +14,11 @@ INSERT INTO invites (
) )
RETURNING *; RETURNING *;
-- name: UpdateInvite :exec
UPDATE invites
SET uses = ?
WHERE id = ?;
-- name: DeleteInvite :exec -- name: DeleteInvite :exec
DELETE FROM invites DELETE FROM invites
WHERE id = ?; WHERE id = ?;

View File

@ -6,7 +6,4 @@ sql:
gen: gen:
go: go:
package: "database" package: "database"
out: "../" out: "../"
overrides:
- db_type: "DATE"
go_type: "go.jolheiser.com/invitea/database/sqlc.Timestamp"

12
main.go
View File

@ -4,15 +4,17 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"github.com/golang-migrate/migrate/v4"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strings"
"go.jolheiser.com/invitea/database" "go.jolheiser.com/invitea/database"
"go.jolheiser.com/invitea/database/sqlc/migrations" "go.jolheiser.com/invitea/database/sqlc/migrations"
"go.jolheiser.com/invitea/router" "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"
"github.com/peterbourgon/ff/v3/ffyaml" "github.com/peterbourgon/ff/v3/ffyaml"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@ -63,11 +65,17 @@ func main() {
log.Fatal().Err(err).Msg("could not migrate database") 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{ r := router.New(router.Config{
Domain: *domainFlag, Domain: *domainFlag,
SessionSecret: *sessionSecretFlag, SessionSecret: *sessionSecretFlag,
Database: database.New(db), Database: database.New(db),
GiteaURL: *giteaURLFlag, GiteaClient: client,
GiteaURL: strings.TrimSuffix(*giteaURLFlag, "/"),
GiteaClientKey: *giteaClientKeyFlag, GiteaClientKey: *giteaClientKeyFlag,
GiteaClientSecret: *giteaClientSecretFlag, GiteaClientSecret: *giteaClientSecretFlag,
GiteaToken: *giteaTokenFlag, GiteaToken: *giteaTokenFlag,

View File

@ -8,6 +8,7 @@ import (
"go.jolheiser.com/invitea/database" "go.jolheiser.com/invitea/database"
"go.jolheiser.com/invitea/static" "go.jolheiser.com/invitea/static"
sdk "code.gitea.io/sdk/gitea"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
@ -21,6 +22,7 @@ type Config struct {
Domain string Domain string
SessionSecret string SessionSecret string
Database *database.Queries Database *database.Queries
GiteaClient *sdk.Client
GiteaURL string GiteaURL string
GiteaClientKey string GiteaClientKey string
@ -50,7 +52,9 @@ func New(cfg Config) *chi.Mux {
store := NewSessionStore(cfg.SessionSecret, cfg.GiteaURL) store := NewSessionStore(cfg.SessionSecret, cfg.GiteaURL)
routes := Routes{ routes := Routes{
DB: cfg.Database, DB: cfg.Database,
Gitea: cfg.GiteaClient,
GiteaURL: cfg.GiteaURL,
} }
mux := chi.NewMux() mux := chi.NewMux()
@ -67,6 +71,12 @@ func New(cfg Config) *chi.Mux {
mux.Route("/", func(r chi.Router) { mux.Route("/", func(r chi.Router) {
r.Use(store.Middleware) r.Use(store.Middleware)
r.Get("/", routes.Index) 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) { 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) 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) { r.Get("/callback", func(w http.ResponseWriter, r *http.Request) {
user, err := gothic.CompleteUserAuth(w, r) user, err := gothic.CompleteUserAuth(w, r)
if err != nil { if err != nil {

View File

@ -1,16 +1,26 @@
package router package router
import ( import (
"crypto/rand"
"database/sql"
"encoding/hex"
"net/http" "net/http"
"strconv"
"time"
"go.jolheiser.com/invitea/api"
"go.jolheiser.com/invitea/database" "go.jolheiser.com/invitea/database"
"go.jolheiser.com/invitea/static" "go.jolheiser.com/invitea/static"
"code.gitea.io/sdk/gitea"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
type Routes struct { type Routes struct {
DB *database.Queries DB *database.Queries
Gitea *gitea.Client
GiteaURL string
} }
func (ro *Routes) Index(w http.ResponseWriter, r *http.Request) { 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 isAdmin = ia
} }
invites, err := ro.DB.ListInvites(r.Context()) dbInvites, err := ro.DB.ListInvites(r.Context())
if err != nil { if err != nil {
log.Err(err).Msg("") log.Err(err).Msg("")
} }
if err := static.Templates.ExecuteTemplate(w, "index.tmpl", map[string]any{ if err := static.Templates.ExecuteTemplate(w, "index.tmpl", map[string]any{
"isAdmin": isAdmin, "isAdmin": isAdmin,
"invites": invites, "username": r.Context().Value("username"),
"invites": api.InvitesFromDB(dbInvites...),
}); err != nil { }); err != nil {
log.Err(err).Msg("") 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)
}

View File

@ -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(), "isAdmin", sess.Values["isAdmin"]))
r = r.WithContext(context.WithValue(r.Context(), "username", sess.Values["username"]))
next.ServeHTTP(w, r) 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["authenticated"] = true
sess.Values["isAdmin"] = profile.IsAdmin 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) return s.Store.Save(r, w, sess)
} }
func NewSessionStore(sessionSecret, giteURL string) *SessionStore { func NewSessionStore(sessionSecret, giteURL string) *SessionStore {
store := sessions.NewCookieStore([]byte(sessionSecret)) store := sessions.NewCookieStore([]byte(sessionSecret))
store.MaxAge(0)
return &SessionStore{ return &SessionStore{
Store: store, Store: store,
GiteaURL: giteURL, GiteaURL: giteURL,

View File

@ -2,7 +2,7 @@
<input type="hidden" name="action" value="create"/> <input type="hidden" name="action" value="create"/>
<p> <p>
<label for="uses">Number of Uses (Leave at 0 for unlimited)</label> <label for="uses">Number of Uses (Leave at 0 for unlimited)</label>
<input id="uses" type="number" name="uses" value="0" min="0"/> <input id="uses" type="number" name="uses" placeholder="0" min="0"/>
</p> </p>
<p> <p>
<label for="expiration">Expiration (Leave blank for no expiration)</label> <label for="expiration">Expiration (Leave blank for no expiration)</label>
@ -24,10 +24,22 @@
<tbody> <tbody>
{{ range .invites }} {{ range .invites }}
<tr> <tr>
<td>{{ .Code }}</td> <td>
<td>{{ .Uses }}/{{ .Total }}</td> {{ if .Valid -}}
<td>{{ .Expiration }}</td> <a href="/invite/{{ .Code }}">{{ .Code }}</a>
<td>Delete</td> {{- else -}}
<code>{{ .Code }}</code>
{{- end }}
</td>
<td>{{ .Uses }} / {{ .TotalString }}</td>
<td>{{ .ExpirationString }}</td>
<td>
<form method="POST">
<input type="hidden" name="action" value="delete"/>
<input type="hidden" name="id" value="{{.ID}}"/>
<button type="submit">Delete</button>
</form>
</td>
</tr> </tr>
{{ end }} {{ end }}
</tbody> </tbody>

View File

@ -12,6 +12,9 @@
</header> </header>
<main> <main>
{{ if .isAdmin }} {{ if .isAdmin }}
<p>
Hello, {{ .username }}. <a href="./auth/logout">Log Out</a>
</p>
{{ template "admin.tmpl" . }} {{ template "admin.tmpl" . }}
{{ else }} {{ else }}
<p> <p>

View File

@ -0,0 +1,50 @@
<html>
<head>
<title>Invitea</title>
<link rel="stylesheet" href="/css/simple.css">
</head>
<body>
<header>
<h1>Invitea</h1>
<p>
Invite Code: <code>{{ .invite.Code }}</code>
</p>
</header>
<main>
<form id="create-form" method="POST">
<p>
<label for="username">Username</label>
<input type="text" id="username" name="username"/>
</p>
<p>
<label for="email">Email</label>
<input type="text" id="email" name="email"/>
</p>
<p>
<label for="password">Password</label>
<input type="password" id="password" name="password"/>
</p>
<p>
<label for="confirm-password">Confirm Password</label>
<input type="password" id="confirm-password" name="confirm-password"/>
</p>
<button id="create" type="button">Create</button>
</form>
</main>
</body>
<script>
document.getElementById("create").addEventListener("click", () => {
const pass1 = document.getElementById("password").value;
const pass2 = document.getElementById("confirm-password").value;
if (pass1 !== pass2) {
alert("Passwords do not match, please re-enter.");
return;
}
document.getElementById("create-form").submit();
});
</script>
</html>