diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index f4fc07f..d6f8b60 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,47 +1,64 @@
-
- - - - - - -
-

Vite + Svelte + Go

+
+ + + + + + +
+

Vite + Svelte + Go

-
- -
+
+ +
-

- Check out SvelteKit, the official Svelte app framework powered by Vite! -

+

+ Check out SvelteKit, the official Svelte app framework powered by Vite! +

-

- Click on the Vite and Svelte logos to learn more -

+ {#if account} +

+ Username: {account.username}, ID: {account.id} +

+ {/if} + +

Click on the Vite and Svelte logos to learn more

diff --git a/frontend/src/lib/api/account.ts b/frontend/src/lib/api/account.ts new file mode 100644 index 0000000..28adff2 --- /dev/null +++ b/frontend/src/lib/api/account.ts @@ -0,0 +1,9 @@ +export interface Account { + id: string; + username: string; + domain: string | null; +} + +export interface MeAccount extends Account { + email: string; +} diff --git a/internal/database/sql/token.go b/internal/database/sql/token.go index 9f224b2..8af1bac 100644 --- a/internal/database/sql/token.go +++ b/internal/database/sql/token.go @@ -34,7 +34,7 @@ func (s *TokenStore) GetApplication(ctx context.Context, id ulid.ULID) (database return app, errors.Wrap(err, "executing query") } -func (s *TokenStore) Create(ctx context.Context, userID, appID ulid.ULID, scopes []string, expires time.Time) (database.Token, error) { +func (s *TokenStore) Create(ctx context.Context, userID, appID ulid.ULID, scopes database.TokenScopes, expires time.Time) (database.Token, error) { q := sqlf.Sprintf(`INSERT INTO tokens (id, user_id, app_id, scopes, expires) values (%s, %s, %s, %v, %v) diff --git a/internal/database/token.go b/internal/database/token.go index 0477d66..f40f582 100644 --- a/internal/database/token.go +++ b/internal/database/token.go @@ -19,7 +19,7 @@ type Token struct { ID ulid.ULID AppID ulid.ULID UserID ulid.ULID - Scopes []string + Scopes TokenScopes Expires time.Time } @@ -43,3 +43,34 @@ type TokenClaims struct { UserID ulid.ULID `json:"sub"` jwt.RegisteredClaims } + +type TokenScope string + +const ( + // All scopes below + TokenScopeAll TokenScope = "all" + TokenScopeAccountsRead TokenScope = "accounts.read" + // Controls whether tokens have access to sensitive account data, NOT if they can use `/accounts/@me` endpoints. + TokenScopeAccountsMe TokenScope = "accounts.me" + TokenScopeAccountsWrite TokenScope = "accounts.write" +) + +func (s TokenScope) IsValid() bool { + switch s { + case TokenScopeAccountsRead, TokenScopeAccountsMe, TokenScopeAccountsWrite: + return true + default: + return false + } +} + +type TokenScopes []TokenScope + +func (s TokenScopes) Has(scope TokenScope) bool { + for i := range s { + if s[i] == scope || s[i] == TokenScopeAll { + return true + } + } + return false +} diff --git a/web/api/user.go b/web/api/user.go new file mode 100644 index 0000000..ecd3822 --- /dev/null +++ b/web/api/user.go @@ -0,0 +1,33 @@ +package api + +import ( + "git.sleepycat.moe/sam/mercury/internal/database" + "github.com/oklog/ulid/v2" +) + +// Account is the basic user returned by endpoints, without any private data. +type Account struct { + ID ulid.ULID `json:"id"` + Username string `json:"username"` + Domain *string `json:"domain"` +} + +type SelfAccount struct { + Account + Email string `json:"email"` +} + +func DBAccountToAccount(a database.Account) Account { + return Account{ + ID: a.ID, + Username: a.Username, + Domain: a.Domain, + } +} + +func DBAccountToSelfAccount(a database.Account) SelfAccount { + return SelfAccount{ + Account: DBAccountToAccount(a), + Email: *a.Email, + } +} diff --git a/web/app/middleware.go b/web/app/middleware.go new file mode 100644 index 0000000..f55cc96 --- /dev/null +++ b/web/app/middleware.go @@ -0,0 +1,53 @@ +package app + +import ( + "context" + "net/http" + "time" + + "git.sleepycat.moe/sam/mercury/internal/database" +) + +type ctxKey int + +const ( + ctxKeyClaims ctxKey = 1 +) + +func (app *App) FrontendAuth(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(database.TokenCookieName) + if err != nil || cookie.Value == "" { + next.ServeHTTP(w, r) + return + } + + token, err := app.ParseToken(r.Context(), cookie.Value) + if err != nil { + app.ErrorTemplate(w, r, "Invalid token", "The provided token was not valid. Try clearing your cookies and reloading the page.") + return + } + + if token.Expires.Before(time.Now()) { + http.SetCookie(w, &http.Cookie{Name: database.TokenCookieName, Value: "", Expires: time.Now()}) + next.ServeHTTP(w, r) + return + } + + ctx := context.WithValue(r.Context(), ctxKeyClaims, token) + + next.ServeHTTP(w, r.WithContext(ctx)) + } + + return http.HandlerFunc(fn) +} + +func (app *App) TokenFromContext(ctx context.Context) (database.Token, bool) { + v := ctx.Value(ctxKeyClaims) + if v == nil { + return database.Token{}, false + } + + claims, ok := v.(database.Token) + return claims, ok +} diff --git a/web/app/template.go b/web/app/template.go index f1e3023..a2bfc68 100644 --- a/web/app/template.go +++ b/web/app/template.go @@ -7,6 +7,13 @@ import ( "github.com/flosch/pongo2/v6" ) +func (app *App) ErrorTemplate(w http.ResponseWriter, r *http.Request, header, desc string) error { + return app.Template(w, r, "error.tpl", pongo2.Context{ + "header": header, + "description": desc, + }) +} + func (app *App) Template(w http.ResponseWriter, r *http.Request, tmplName string, ctx pongo2.Context) error { tmpl, err := app.tmpl.FromCache(tmplName) if err != nil { diff --git a/web/auth/login.go b/web/auth/login.go index 194a633..523b5eb 100644 --- a/web/auth/login.go +++ b/web/auth/login.go @@ -70,7 +70,7 @@ func (app *Auth) PostLogin(w http.ResponseWriter, r *http.Request) { // create a new token token, err := app.Token(conn).Create( - ctx, acct.ID, *app.DBConfig.Get().InternalApplication, []string{"all"}, time.Now().Add(math.MaxInt64)) + ctx, acct.ID, *app.DBConfig.Get().InternalApplication, database.TokenScopes{database.TokenScopeAll}, time.Now().Add(math.MaxInt64)) if err != nil { log.Err(err).Msg("creating token") return diff --git a/web/frontend/app.html b/web/frontend/app.html index 4110bf2..39cb513 100644 --- a/web/frontend/app.html +++ b/web/frontend/app.html @@ -9,5 +9,7 @@
+ + diff --git a/web/frontend/frontend.go b/web/frontend/frontend.go index 2788422..2f856a6 100644 --- a/web/frontend/frontend.go +++ b/web/frontend/frontend.go @@ -2,6 +2,7 @@ package frontend import ( "embed" + "encoding/json" "html/template" "net/http" "os" @@ -9,6 +10,7 @@ import ( "git.sleepycat.moe/sam/mercury/frontend" "git.sleepycat.moe/sam/mercury/internal/database" + "git.sleepycat.moe/sam/mercury/web/api" "git.sleepycat.moe/sam/mercury/web/app" "github.com/go-chi/chi/v5" "github.com/rs/zerolog/log" @@ -89,10 +91,65 @@ func (app *Frontend) ServeAssets(w http.ResponseWriter, r *http.Request) { } func (app *Frontend) ServeFrontend(w http.ResponseWriter, r *http.Request) { + _, ok := app.TokenFromContext(r.Context()) + if !ok { + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) + return + } + + app.serveFrontend(w, r) +} + +func (app *Frontend) ServeUser(w http.ResponseWriter, r *http.Request) { + _, ok := app.TokenFromContext(r.Context()) + if !ok { + username := chi.URLParam(r, "username") + http.Redirect(w, r, "/@"+username, http.StatusSeeOther) + return + } + + app.serveFrontend(w, r) +} + +func (app *Frontend) ServeStatus(w http.ResponseWriter, r *http.Request) { + _, ok := app.TokenFromContext(r.Context()) + if !ok { + username := chi.URLParam(r, "username") + postID := chi.URLParam(r, "postID") + http.Redirect(w, r, "/@"+username+"/posts/"+postID, http.StatusSeeOther) + return + } + + app.serveFrontend(w, r) +} + +func (app *Frontend) serveFrontend(w http.ResponseWriter, r *http.Request) { + token, ok := app.TokenFromContext(r.Context()) + if !ok { + log.Error().Msg("app.serveFrontend was called without a token being set") + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) + return + } + + acct, err := app.Account().ByID(r.Context(), token.UserID) + if err != nil { + log.Err(err).Msg("fetching account") + app.ErrorTemplate(w, r, "Internal server error", "An internal server error occurred. Please try again later.") + return + } + + b, err := json.Marshal(api.DBAccountToSelfAccount(acct)) + if err != nil { + log.Err(err).Msg("marshaling account json") + app.ErrorTemplate(w, r, "Internal server error", "An internal server error occurred. Please try again later.") + return + } + w.Header().Set("Content-Type", "text/html") - err := app.tmpl.Execute(w, map[string]any{ - "Config": database.DefaultConfig, - "Vue": app.glue, + err = app.tmpl.Execute(w, map[string]any{ + "Config": database.DefaultConfig, + "Vue": app.glue, + "AccountData": template.HTML(b), }) if err != nil { log.Err(err).Msg("executing frontend template") diff --git a/web/routes.go b/web/routes.go index 8518bb6..a383c41 100644 --- a/web/routes.go +++ b/web/routes.go @@ -22,9 +22,13 @@ func Routes(app *app.App) { // also assets app.Router.Group(func(r chi.Router) { frontend := frontend.New(app) + r.Use(app.FrontendAuth) + r.HandleFunc(frontend.AssetsPath(), frontend.ServeAssets) r.HandleFunc("/static/*", frontend.ServeStaticAssets) r.HandleFunc("/web", frontend.ServeFrontend) r.HandleFunc("/web/*", frontend.ServeFrontend) + r.HandleFunc("/web/@{username}", frontend.ServeUser) + r.HandleFunc("/web/@{username}/posts/{postID}", frontend.ServeStatus) }) } diff --git a/web/templates/error.tpl b/web/templates/error.tpl new file mode 100644 index 0000000..2c94a09 --- /dev/null +++ b/web/templates/error.tpl @@ -0,0 +1,10 @@ +{% extends 'base.tpl' %} +{% block title %} + Error • {{ config.Name }} +{% endblock %} +{% block content %} +
+

{{ heading }}

+

{{ description }}

+
+{% endblock %}