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 (
"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
}

View File

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

View File

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

View File

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

View File

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

12
main.go
View File

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

View File

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

View File

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

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(), "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,

View File

@ -2,7 +2,7 @@
<input type="hidden" name="action" value="create"/>
<p>
<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>
<label for="expiration">Expiration (Leave blank for no expiration)</label>
@ -24,10 +24,22 @@
<tbody>
{{ range .invites }}
<tr>
<td>{{ .Code }}</td>
<td>{{ .Uses }}/{{ .Total }}</td>
<td>{{ .Expiration }}</td>
<td>Delete</td>
<td>
{{ if .Valid -}}
<a href="/invite/{{ .Code }}">{{ .Code }}</a>
{{- 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>
{{ end }}
</tbody>

View File

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