parent
d5717f0e7d
commit
d17d17f641
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
|
@ -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 = ?;
|
||||
|
|
|
@ -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
12
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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
154
router/routes.go
154
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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -12,6 +12,9 @@
|
|||
</header>
|
||||
<main>
|
||||
{{ if .isAdmin }}
|
||||
<p>
|
||||
Hello, {{ .username }}. <a href="./auth/logout">Log Out</a>
|
||||
</p>
|
||||
{{ template "admin.tmpl" . }}
|
||||
{{ else }}
|
||||
<p>
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue