add frontend auth middleware, embed user data in frontend html
This commit is contained in:
parent
d8cb8c8fa8
commit
0fa769a248
12 changed files with 265 additions and 42 deletions
|
@ -1,47 +1,64 @@
|
|||
<script lang="ts">
|
||||
import svelteLogo from './assets/svelte.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import Counter from './lib/Counter.svelte'
|
||||
import { onMount } from "svelte";
|
||||
import svelteLogo from "./assets/svelte.svg";
|
||||
import viteLogo from "./assets/vite.svg";
|
||||
import Counter from "./lib/Counter.svelte";
|
||||
import type { MeAccount } from "./lib/api/account";
|
||||
|
||||
let account: MeAccount | null = null;
|
||||
|
||||
onMount(() => {
|
||||
const accountData = document.getElementById("accountData");
|
||||
account = JSON.parse(accountData.innerHTML) as MeAccount;
|
||||
});
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div>
|
||||
<a href="https://vitejs.dev" target="_blank" rel="noreferrer">
|
||||
<img src={viteLogo} class="logo" alt="Vite Logo" />
|
||||
</a>
|
||||
<a href="https://svelte.dev" target="_blank" rel="noreferrer">
|
||||
<img src={svelteLogo} class="logo svelte" alt="Svelte Logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + Svelte + Go</h1>
|
||||
<div>
|
||||
<a href="https://vitejs.dev" target="_blank" rel="noreferrer">
|
||||
<img src={viteLogo} class="logo" alt="Vite Logo" />
|
||||
</a>
|
||||
<a href="https://svelte.dev" target="_blank" rel="noreferrer">
|
||||
<img src={svelteLogo} class="logo svelte" alt="Svelte Logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + Svelte + Go</h1>
|
||||
|
||||
<div class="card">
|
||||
<Counter />
|
||||
</div>
|
||||
<div class="card">
|
||||
<Counter />
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out <a href="https://github.com/sveltejs/kit#readme" target="_blank" rel="noreferrer">SvelteKit</a>, the official Svelte app framework powered by Vite!
|
||||
</p>
|
||||
<p>
|
||||
Check out <a
|
||||
href="https://github.com/sveltejs/kit#readme"
|
||||
target="_blank"
|
||||
rel="noreferrer">SvelteKit</a
|
||||
>, the official Svelte app framework powered by Vite!
|
||||
</p>
|
||||
|
||||
<p class="read-the-docs">
|
||||
Click on the Vite and Svelte logos to learn more
|
||||
</p>
|
||||
{#if account}
|
||||
<p>
|
||||
Username: {account.username}, ID: {account.id}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="read-the-docs">Click on the Vite and Svelte logos to learn more</p>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.svelte:hover {
|
||||
filter: drop-shadow(0 0 2em #ff3e00aa);
|
||||
}
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.svelte:hover {
|
||||
filter: drop-shadow(0 0 2em #ff3e00aa);
|
||||
}
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
|
|
9
frontend/src/lib/api/account.ts
Normal file
9
frontend/src/lib/api/account.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export interface Account {
|
||||
id: string;
|
||||
username: string;
|
||||
domain: string | null;
|
||||
}
|
||||
|
||||
export interface MeAccount extends Account {
|
||||
email: string;
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
33
web/api/user.go
Normal file
33
web/api/user.go
Normal file
|
@ -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,
|
||||
}
|
||||
}
|
53
web/app/middleware.go
Normal file
53
web/app/middleware.go
Normal file
|
@ -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
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -9,5 +9,7 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<script type="text/json" id="accountData">{{.AccountData}}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
10
web/templates/error.tpl
Normal file
10
web/templates/error.tpl
Normal file
|
@ -0,0 +1,10 @@
|
|||
{% extends 'base.tpl' %}
|
||||
{% block title %}
|
||||
Error • {{ config.Name }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error">
|
||||
<h1>{{ heading }}</h1>
|
||||
<p>{{ description }}</p>
|
||||
</div>
|
||||
{% endblock %}
|
Loading…
Reference in a new issue