Merge remote-tracking branch 'origin/stable'

This commit is contained in:
Kay Faraday 2024-07-18 23:48:56 -07:00
commit 7a4518184b
124 changed files with 4166 additions and 3495 deletions

13
.woodpecker/.backend.yml Normal file
View File

@ -0,0 +1,13 @@
when:
branch:
exclude: stable
steps:
check:
image: golang:alpine
commands:
- apk update && apk add curl vips-dev build-base
- make backend
# Install golangci-lint
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2
- golangci-lint run

20
.woodpecker/.frontend.yml Normal file
View File

@ -0,0 +1,20 @@
when:
branch:
exclude: stable
steps:
check:
image: node
directory: frontend
environment: # SvelteKit expects these in the environment during build time.
- PRIVATE_SENTRY_DSN=
- PUBLIC_BASE_URL=http://pronouns.localhost
- PUBLIC_MEDIA_URL=http://pronouns.localhost/media
- PUBLIC_SHORT_BASE=http://prns.localhost
- PUBLIC_HCAPTCHA_SITEKEY=non_existent_sitekey
commands:
- corepack enable
- pnpm install
- pnpm check
- pnpm lint
- pnpm build

View File

@ -2,7 +2,7 @@ all: generate backend frontend
.PHONY: backend .PHONY: backend
backend: backend:
go build -v -o pronouns -ldflags="-buildid= -X codeberg.org/pronounscc/pronouns.cc/backend/server.Revision=`git rev-parse --short HEAD` -X codeberg.org/pronounscc/pronouns.cc/backend/server.Tag=`git describe --tags --long`" . go build -v -o pronouns -ldflags="-buildid= -X codeberg.org/pronounscc/pronouns.cc/backend/server.Revision=`git rev-parse --short HEAD` -X codeberg.org/pronounscc/pronouns.cc/backend/server.Tag=`git describe --tags --long --always`" .
.PHONY: generate .PHONY: generate
generate: generate:

View File

@ -79,7 +79,7 @@ func (db *DB) CreateExport(ctx context.Context, userID xid.ID, filename string,
return de, errors.Wrap(err, "building query") return de, errors.Wrap(err, "building query")
} }
pgxscan.Get(ctx, db, &de, sql, args...) err = pgxscan.Get(ctx, db, &de, sql, args...)
if err != nil { if err != nil {
return de, errors.Wrap(err, "executing sql") return de, errors.Wrap(err, "executing sql")
} }

View File

@ -6,6 +6,7 @@ import (
"encoding/base64" "encoding/base64"
"time" "time"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/georgysavva/scany/v2/pgxscan" "github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@ -43,7 +44,12 @@ func (db *DB) CreateInvite(ctx context.Context, userID xid.ID) (i Invite, err er
if err != nil { if err != nil {
return i, errors.Wrap(err, "beginning transaction") return i, errors.Wrap(err, "beginning transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
var maxInvites, inviteCount int var maxInvites, inviteCount int
err = tx.QueryRow(ctx, "SELECT max_invites FROM users WHERE id = $1", userID).Scan(&maxInvites) err = tx.QueryRow(ctx, "SELECT max_invites FROM users WHERE id = $1", userID).Scan(&maxInvites)

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"codeberg.org/pronounscc/pronouns.cc/backend/common" "codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/Masterminds/squirrel" "github.com/Masterminds/squirrel"
"github.com/georgysavva/scany/v2/pgxscan" "github.com/georgysavva/scany/v2/pgxscan"
@ -41,12 +42,14 @@ const (
) )
// member names must match this regex // member names must match this regex
var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,]{1,100}$") var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,\\*]{1,100}$")
// List of member names that cannot be used because they would break routing or be inaccessible due to page conflicts. // List of member names that cannot be used because they would break routing or be inaccessible due to page conflicts.
var invalidMemberNames = []string{ var invalidMemberNames = []string{
// these break routing outright
".", ".",
"..", "..",
// the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible
"edit", "edit",
} }
@ -285,7 +288,12 @@ func (db *DB) RerollMemberSID(ctx context.Context, userID, memberID xid.ID) (new
if err != nil { if err != nil {
return "", errors.Wrap(err, "beginning transaction") return "", errors.Wrap(err, "beginning transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
sql, args, err := sq.Update("members"). sql, args, err := sq.Update("members").
Set("sid", squirrel.Expr("find_free_member_sid()")). Set("sid", squirrel.Expr("find_free_member_sid()")).

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/davidbyttow/govips/v2/vips" "github.com/davidbyttow/govips/v2/vips"
"github.com/getsentry/sentry-go"
"github.com/go-chi/render" "github.com/go-chi/render"
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
@ -23,6 +24,19 @@ var Command = &cli.Command{
} }
func run(c *cli.Context) error { func run(c *cli.Context) error {
// initialize sentry
if dsn := os.Getenv("SENTRY_DSN"); dsn != "" {
// We don't need to check the error here--it's fine if no DSN is set.
_ = sentry.Init(sentry.ClientOptions{
Dsn: dsn,
Debug: os.Getenv("DEBUG") == "true",
Release: server.Tag,
EnableTracing: os.Getenv("SENTRY_TRACING") == "true",
TracesSampleRate: 0.05,
ProfilesSampleRate: 0.05,
})
}
// set vips log level to WARN, else it will spam logs on info level // set vips log level to WARN, else it will spam logs on info level
vips.LoggingSettings(nil, vips.LogLevelWarning) vips.LoggingSettings(nil, vips.LogLevelWarning)

View File

@ -2,7 +2,6 @@ package backend
import ( import (
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/auth" "codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/auth"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/bot"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/member" "codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/member"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/meta" "codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/meta"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/mod" "codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/mod"
@ -21,7 +20,6 @@ func mountRoutes(s *server.Server) {
auth.Mount(s, r) auth.Mount(s, r)
user.Mount(s, r) user.Mount(s, r)
member.Mount(s, r) member.Mount(s, r)
bot.Mount(s, r)
meta.Mount(s, r) meta.Mount(s, r)
mod.Mount(s, r) mod.Mount(s, r)
}) })

View File

@ -11,6 +11,7 @@ import (
"emperror.dev/errors" "emperror.dev/errors"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4"
"github.com/rs/xid" "github.com/rs/xid"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@ -61,7 +62,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
// if the state can't be validated, return // if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil { if err != nil {
return err return errors.Wrap(err, "validating state")
} }
return server.APIError{Code: server.ErrInvalidState} return server.APIError{Code: server.ErrInvalidState}
@ -79,7 +80,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
dg, _ := discordgo.New(token.Type() + " " + token.AccessToken) dg, _ := discordgo.New(token.Type() + " " + token.AccessToken)
du, err := dg.User("@me") du, err := dg.User("@me")
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting discord user")
} }
u, err := s.DB.DiscordUser(ctx, du.ID) u, err := s.DB.DiscordUser(ctx, du.ID)
@ -90,7 +91,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token) err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil { if err != nil {
log.Errorf("saving undelete token: %v", err) log.Errorf("saving undelete token: %v", err)
return err return errors.Wrap(err, "saving undelete token")
} }
render.JSON(w, r, discordCallbackResponse{ render.JSON(w, r, discordCallbackResponse{
@ -114,7 +115,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New() tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil { if err != nil {
return err return errors.Wrap(err, "creating token")
} }
// save token to database // save token to database
@ -137,7 +138,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} else if err != db.ErrUserNotFound { // internal error } else if err != db.ErrUserNotFound { // internal error
return err return errors.Wrap(err, "getting user")
} }
// no user found, so save a ticket + save their Discord info in Redis // no user found, so save a ticket + save their Discord info in Redis
@ -145,7 +146,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600") err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600")
if err != nil { if err != nil {
log.Errorf("setting Discord user for ticket %q: %v", ticket, err) log.Errorf("setting Discord user for ticket %q: %v", ticket, err)
return err return errors.Wrap(err, "caching discord user for ticket")
} }
render.JSON(w, r, discordCallbackResponse{ render.JSON(w, r, discordCallbackResponse{
@ -278,7 +279,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil { if err != nil {
return err return errors.Wrap(err, "checking if username is taken")
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -291,7 +292,12 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "beginning transaction") return errors.Wrap(err, "beginning transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
du := new(discordgo.User) du := new(discordgo.User)
err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du) err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du)

View File

@ -11,6 +11,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4"
"github.com/rs/xid" "github.com/rs/xid"
) )
@ -54,7 +55,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
// if the state can't be validated, return // if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil { if err != nil {
return err return errors.Wrap(err, "validating state")
} }
return server.APIError{Code: server.ErrInvalidState} return server.APIError{Code: server.ErrInvalidState}
@ -111,7 +112,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
err = s.saveUndeleteToken(ctx, u.ID, token) err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil { if err != nil {
log.Errorf("saving undelete token: %v", err) log.Errorf("saving undelete token: %v", err)
return err return errors.Wrap(err, "saving undelete token")
} }
render.JSON(w, r, fediCallbackResponse{ render.JSON(w, r, fediCallbackResponse{
@ -135,7 +136,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
tokenID := xid.New() tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil { if err != nil {
return err return errors.Wrap(err, "creating token")
} }
// save token to database // save token to database
@ -158,7 +159,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
return nil return nil
} else if err != db.ErrUserNotFound { // internal error } else if err != db.ErrUserNotFound { // internal error
return err return errors.Wrap(err, "getting user")
} }
// no user found, so save a ticket + save their Mastodon info in Redis // no user found, so save a ticket + save their Mastodon info in Redis
@ -166,7 +167,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600") err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600")
if err != nil { if err != nil {
log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err) log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err)
return err return errors.Wrap(err, "setting user for ticket")
} }
render.JSON(w, r, fediCallbackResponse{ render.JSON(w, r, fediCallbackResponse{
@ -306,7 +307,7 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil { if err != nil {
return err return errors.Wrap(err, "checking if username is taken")
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -319,7 +320,12 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "beginning transaction") return errors.Wrap(err, "beginning transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
mu := new(partialMastodonAccount) mu := new(partialMastodonAccount)
err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu) err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu)

View File

@ -12,6 +12,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4"
"github.com/rs/xid" "github.com/rs/xid"
) )
@ -90,7 +91,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token) err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil { if err != nil {
log.Errorf("saving undelete token: %v", err) log.Errorf("saving undelete token: %v", err)
return err return errors.Wrap(err, "saving undelete token")
} }
render.JSON(w, r, fediCallbackResponse{ render.JSON(w, r, fediCallbackResponse{
@ -114,7 +115,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New() tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil { if err != nil {
return err return errors.Wrap(err, "creating token")
} }
// save token to database // save token to database
@ -137,7 +138,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} else if err != db.ErrUserNotFound { // internal error } else if err != db.ErrUserNotFound { // internal error
return err return errors.Wrap(err, "getting user")
} }
// no user found, so save a ticket + save their Misskey info in Redis // no user found, so save a ticket + save their Misskey info in Redis
@ -145,7 +146,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu.User, "EX", "600") err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu.User, "EX", "600")
if err != nil { if err != nil {
log.Errorf("setting misskey user for ticket %q: %v", ticket, err) log.Errorf("setting misskey user for ticket %q: %v", ticket, err)
return err return errors.Wrap(err, "setting user for ticket")
} }
render.JSON(w, r, fediCallbackResponse{ render.JSON(w, r, fediCallbackResponse{
@ -234,7 +235,7 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil { if err != nil {
return err return errors.Wrap(err, "checking if username is taken")
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -247,7 +248,12 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "beginning transaction") return errors.Wrap(err, "beginning transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
mu := new(partialMisskeyAccount) mu := new(partialMisskeyAccount)
err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu) err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu)

View File

@ -10,6 +10,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4"
"github.com/rs/xid" "github.com/rs/xid"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@ -60,7 +61,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
// if the state can't be validated, return // if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil { if err != nil {
return err return errors.Wrap(err, "validating state")
} }
return server.APIError{Code: server.ErrInvalidState} return server.APIError{Code: server.ErrInvalidState}
@ -109,7 +110,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token) err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil { if err != nil {
log.Errorf("saving undelete token: %v", err) log.Errorf("saving undelete token: %v", err)
return err return errors.Wrap(err, "saving undelete token")
} }
render.JSON(w, r, googleCallbackResponse{ render.JSON(w, r, googleCallbackResponse{
@ -133,7 +134,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New() tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil { if err != nil {
return err return errors.Wrap(err, "creating token")
} }
// save token to database // save token to database
@ -156,7 +157,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} else if err != db.ErrUserNotFound { // internal error } else if err != db.ErrUserNotFound { // internal error
return err return errors.Wrap(err, "getting user")
} }
// no user found, so save a ticket + save their Google info in Redis // no user found, so save a ticket + save their Google info in Redis
@ -164,7 +165,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600") err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600")
if err != nil { if err != nil {
log.Errorf("setting Google user for ticket %q: %v", ticket, err) log.Errorf("setting Google user for ticket %q: %v", ticket, err)
return err return errors.Wrap(err, "setting user for ticket")
} }
render.JSON(w, r, googleCallbackResponse{ render.JSON(w, r, googleCallbackResponse{
@ -281,7 +282,7 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil { if err != nil {
return err return errors.Wrap(err, "checking if username is taken")
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -294,7 +295,12 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "beginning transaction") return errors.Wrap(err, "beginning transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
gu := new(partialGoogleUser) gu := new(partialGoogleUser)
err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu) err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu)

View File

@ -185,7 +185,7 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
if googleOAuthConfig.ClientID != "" { if googleOAuthConfig.ClientID != "" {
googleCfg := googleOAuthConfig googleCfg := googleOAuthConfig
googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google" googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google"
resp.Google = googleCfg.AuthCodeURL(state) resp.Google = googleCfg.AuthCodeURL(state) + "&prompt=select_account"
} }
render.JSON(w, r, resp) render.JSON(w, r, resp)

View File

@ -5,9 +5,11 @@ import (
"time" "time"
"codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/rs/xid" "github.com/rs/xid"
) )
@ -63,7 +65,12 @@ func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "beginning transaction") return errors.Wrap(err, "beginning transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
err = s.DB.InvalidateAllTokens(ctx, tx, claims.UserID) err = s.DB.InvalidateAllTokens(ctx, tx, claims.UserID)
if err != nil { if err != nil {

View File

@ -12,6 +12,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4"
"github.com/rs/xid" "github.com/rs/xid"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@ -77,7 +78,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
// if the state can't be validated, return // if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil { if err != nil {
return err return errors.Wrap(err, "validating state")
} }
return server.APIError{Code: server.ErrInvalidState} return server.APIError{Code: server.ErrInvalidState}
@ -142,7 +143,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token) err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil { if err != nil {
log.Errorf("saving undelete token: %v", err) log.Errorf("saving undelete token: %v", err)
return err return errors.Wrap(err, "saving undelete token")
} }
render.JSON(w, r, tumblrCallbackResponse{ render.JSON(w, r, tumblrCallbackResponse{
@ -166,7 +167,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New() tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil { if err != nil {
return err return errors.Wrap(err, "creating token")
} }
// save token to database // save token to database
@ -189,7 +190,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} else if err != db.ErrUserNotFound { // internal error } else if err != db.ErrUserNotFound { // internal error
return err return errors.Wrap(err, "getting user")
} }
// no user found, so save a ticket + save their Tumblr info in Redis // no user found, so save a ticket + save their Tumblr info in Redis
@ -197,7 +198,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600") err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600")
if err != nil { if err != nil {
log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err) log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err)
return err return errors.Wrap(err, "setting user for ticket")
} }
render.JSON(w, r, tumblrCallbackResponse{ render.JSON(w, r, tumblrCallbackResponse{
@ -314,7 +315,7 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil { if err != nil {
return err return errors.Wrap(err, "checking if username is taken")
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -327,7 +328,12 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "beginning transaction") return errors.Wrap(err, "beginning transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
tui := new(tumblrUserInfo) tui := new(tumblrUserInfo)
err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui) err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui)

View File

@ -1,183 +0,0 @@
package bot
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/bwmarrin/discordgo"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
type Bot struct {
*server.Server
publicKey ed25519.PublicKey
baseURL string
}
func (bot *Bot) UserAvatarURL(u db.User) string {
if u.Avatar == nil {
return ""
}
return bot.baseURL + "/media/users/" + u.ID.String() + "/" + *u.Avatar + ".webp"
}
func Mount(srv *server.Server, r chi.Router) {
publicKey, err := hex.DecodeString(os.Getenv("DISCORD_PUBLIC_KEY"))
if err != nil {
return
}
b := &Bot{
Server: srv,
publicKey: publicKey,
baseURL: os.Getenv("BASE_URL"),
}
r.HandleFunc("/interactions", b.handle)
}
func (bot *Bot) handle(w http.ResponseWriter, r *http.Request) {
if !discordgo.VerifyInteraction(r, bot.publicKey) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
var ev *discordgo.InteractionCreate
if err := json.NewDecoder(r.Body).Decode(&ev); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
}
// we can always respond to ping with pong
if ev.Type == discordgo.InteractionPing {
log.Debug("received ping interaction")
render.JSON(w, r, discordgo.InteractionResponse{
Type: discordgo.InteractionResponsePong,
})
return
}
if ev.Type != discordgo.InteractionApplicationCommand {
return
}
data := ev.ApplicationCommandData()
switch data.Name {
case "Show user's pronouns":
bot.userPronouns(w, r, ev)
case "Show author's pronouns":
}
}
func (bot *Bot) userPronouns(w http.ResponseWriter, r *http.Request, ev *discordgo.InteractionCreate) {
ctx := r.Context()
var du *discordgo.User
for _, user := range ev.ApplicationCommandData().Resolved.Users {
du = user
break
}
if du == nil {
return
}
u, err := bot.DB.DiscordUser(ctx, du.ID)
if err != nil {
if err == db.ErrUserNotFound {
respond(w, r, &discordgo.MessageEmbed{
Description: du.String() + " does not have any pronouns set.",
})
return
}
log.Errorf("getting discord user: %v", err)
return
}
avatarURL := du.AvatarURL("")
if url := bot.UserAvatarURL(u); url != "" {
avatarURL = url
}
name := u.Username
if u.DisplayName != nil {
name = fmt.Sprintf("%s (%s)", *u.DisplayName, u.Username)
}
url := bot.baseURL
if url != "" {
url += "/@" + u.Username
}
e := &discordgo.MessageEmbed{
Author: &discordgo.MessageEmbedAuthor{
Name: name,
IconURL: avatarURL,
URL: url,
},
}
if u.Bio != nil {
e.Fields = append(e.Fields, &discordgo.MessageEmbedField{
Name: "Bio",
Value: *u.Bio,
})
}
fields, err := bot.DB.UserFields(ctx, u.ID)
if err != nil {
respond(w, r, e)
log.Errorf("getting user fields: %v", err)
return
}
for _, field := range fields {
var favs []db.FieldEntry
for _, e := range field.Entries {
if e.Status == "favourite" {
favs = append(favs, e)
}
}
if len(favs) == 0 {
continue
}
var value string
for _, fav := range favs {
if len(fav.Value) > 500 {
break
}
value += fav.Value + "\n"
}
e.Fields = append(e.Fields, &discordgo.MessageEmbedField{
Name: field.Name,
Value: value,
Inline: true,
})
}
respond(w, r, e)
}
func respond(w http.ResponseWriter, r *http.Request, embeds ...*discordgo.MessageEmbed) {
render.JSON(w, r, discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Embeds: embeds,
Flags: discordgo.MessageFlagsEphemeral,
},
})
}

View File

@ -11,6 +11,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
) )
type CreateMemberRequest struct { type CreateMemberRequest struct {
@ -119,7 +120,12 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
if err != nil { if err != nil {
return errors.Wrap(err, "starting transaction") return errors.Wrap(err, "starting transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links) m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links)
if err != nil { if err != nil {
@ -127,14 +133,14 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
return server.APIError{Code: server.ErrMemberNameInUse} return server.APIError{Code: server.ErrMemberNameInUse}
} }
return err return errors.Wrap(err, "creating member")
} }
// set names, pronouns, fields // set names, pronouns, fields
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns)) err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns))
if err != nil { if err != nil {
log.Errorf("setting names and pronouns for member %v: %v", m.ID, err) log.Errorf("setting names and pronouns for member %v: %v", m.ID, err)
return err return errors.Wrap(err, "setting names/pronouns")
} }
m.Names = cmr.Names m.Names = cmr.Names
m.Pronouns = cmr.Pronouns m.Pronouns = cmr.Pronouns
@ -142,7 +148,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields) err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields)
if err != nil { if err != nil {
log.Errorf("setting fields for member %v: %v", m.ID, err) log.Errorf("setting fields for member %v: %v", m.ID, err)
return err return errors.Wrap(err, "setting fields")
} }
if cmr.Avatar != "" { if cmr.Avatar != "" {
@ -161,13 +167,13 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
} }
log.Errorf("converting member avatar: %v", err) log.Errorf("converting member avatar: %v", err)
return err return errors.Wrap(err, "converting avatar")
} }
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg) hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
if err != nil { if err != nil {
log.Errorf("uploading member avatar: %v", err) log.Errorf("uploading member avatar: %v", err)
return err return errors.Wrap(err, "uploading avatar")
} }
err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar) err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar)
@ -180,7 +186,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil { if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err) log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "updating last active time")
} }
err = tx.Commit(ctx) err = tx.Commit(ctx)

View File

@ -66,7 +66,7 @@ func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) (err error
err = s.DB.UpdateActiveTime(ctx, s.DB, claims.UserID) err = s.DB.UpdateActiveTime(ctx, s.DB, claims.UserID)
if err != nil { if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err) log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "updating last active time")
} }
render.NoContent(w, r) render.NoContent(w, r)

View File

@ -105,7 +105,7 @@ func (s *Server) getMember(w http.ResponseWriter, r *http.Request) (err error) {
u, err := s.DB.User(ctx, m.UserID) u, err := s.DB.User(ctx, m.UserID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting user")
} }
if u.DeletedAt != nil { if u.DeletedAt != nil {
@ -119,12 +119,12 @@ func (s *Server) getMember(w http.ResponseWriter, r *http.Request) (err error) {
fields, err := s.DB.MemberFields(ctx, m.ID) fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting member fields")
} }
flags, err := s.DB.MemberFlags(ctx, m.ID) flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting member flags")
} }
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember)) render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
@ -159,12 +159,12 @@ func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error {
fields, err := s.DB.MemberFields(ctx, m.ID) fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting member fields")
} }
flags, err := s.DB.MemberFlags(ctx, m.ID) flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting member flags")
} }
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember)) render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
@ -189,12 +189,12 @@ func (s *Server) getMeMember(w http.ResponseWriter, r *http.Request) error {
fields, err := s.DB.MemberFields(ctx, m.ID) fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting member fields")
} }
flags, err := s.DB.MemberFlags(ctx, m.ID) flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting member flags")
} }
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true)) render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))

View File

@ -6,6 +6,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/common" "codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/rs/xid" "github.com/rs/xid"
@ -74,7 +75,7 @@ func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error {
ms, err := s.DB.UserMembers(ctx, u.ID, isSelf) ms, err := s.DB.UserMembers(ctx, u.ID, isSelf)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting members")
} }
render.JSON(w, r, membersToMemberList(ms, isSelf)) render.JSON(w, r, membersToMemberList(ms, isSelf))
@ -87,7 +88,7 @@ func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error {
ms, err := s.DB.UserMembers(ctx, claims.UserID, true) ms, err := s.DB.UserMembers(ctx, claims.UserID, true)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting members")
} }
render.JSON(w, r, membersToMemberList(ms, true)) render.JSON(w, r, membersToMemberList(ms, true))

View File

@ -13,6 +13,7 @@ import (
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/rs/xid" "github.com/rs/xid"
) )
@ -220,13 +221,13 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
} }
log.Errorf("converting member avatar: %v", err) log.Errorf("converting member avatar: %v", err)
return err return errors.Wrap(err, "converting member avatar")
} }
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg) hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
if err != nil { if err != nil {
log.Errorf("uploading member avatar: %v", err) log.Errorf("uploading member avatar: %v", err)
return err return errors.Wrap(err, "writing member avatar")
} }
avatarHash = &hash avatarHash = &hash
@ -244,9 +245,14 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
tx, err := s.DB.Begin(ctx) tx, err := s.DB.Begin(ctx)
if err != nil { if err != nil {
log.Errorf("creating transaction: %v", err) log.Errorf("creating transaction: %v", err)
return err return errors.Wrap(err, "creating transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
m, err = s.DB.UpdateMember(ctx, tx, m.ID, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash) m, err = s.DB.UpdateMember(ctx, tx, m.ID, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash)
if err != nil { if err != nil {
@ -275,7 +281,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, names, pronouns) err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, names, pronouns)
if err != nil { if err != nil {
log.Errorf("setting names for member %v: %v", m.ID, err) log.Errorf("setting names for member %v: %v", m.ID, err)
return err return errors.Wrap(err, "setting names/pronouns")
} }
m.Names = names m.Names = names
m.Pronouns = pronouns m.Pronouns = pronouns
@ -286,14 +292,14 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetMemberFields(ctx, tx, m.ID, *req.Fields) err = s.DB.SetMemberFields(ctx, tx, m.ID, *req.Fields)
if err != nil { if err != nil {
log.Errorf("setting fields for member %v: %v", m.ID, err) log.Errorf("setting fields for member %v: %v", m.ID, err)
return err return errors.Wrap(err, "setting fields")
} }
fields = *req.Fields fields = *req.Fields
} else { } else {
fields, err = s.DB.MemberFields(ctx, m.ID) fields, err = s.DB.MemberFields(ctx, m.ID)
if err != nil { if err != nil {
log.Errorf("getting fields for member %v: %v", m.ID, err) log.Errorf("getting fields for member %v: %v", m.ID, err)
return err return errors.Wrap(err, "getting fields")
} }
} }
@ -306,7 +312,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
} }
log.Errorf("updating flags for member %v: %v", m.ID, err) log.Errorf("updating flags for member %v: %v", m.ID, err)
return err return errors.Wrap(err, "updating flags")
} }
} }
@ -314,20 +320,20 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil { if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err) log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "updating last active time")
} }
err = tx.Commit(ctx) err = tx.Commit(ctx)
if err != nil { if err != nil {
log.Errorf("committing transaction: %v", err) log.Errorf("committing transaction: %v", err)
return err return errors.Wrap(err, "committing transaction")
} }
// get flags to return (we need to return full flag objects, not the array of IDs in the request body) // get flags to return (we need to return full flag objects, not the array of IDs in the request body)
flags, err := s.DB.MemberFlags(ctx, m.ID) flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil { if err != nil {
log.Errorf("getting user flags: %v", err) log.Errorf("getting user flags: %v", err)
return err return errors.Wrap(err, "getting flags")
} }
// echo the updated member back on success // echo the updated member back on success

View File

@ -10,6 +10,7 @@ import (
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
) )
type resolveReportRequest struct { type resolveReportRequest struct {
@ -43,7 +44,12 @@ func (s *Server) resolveReport(w http.ResponseWriter, r *http.Request) error {
log.Errorf("creating transaction: %v", err) log.Errorf("creating transaction: %v", err)
return errors.Wrap(err, "creating transaction") return errors.Wrap(err, "creating transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
report, err := s.DB.Report(ctx, tx, id) report, err := s.DB.Report(ctx, tx, id)
if err != nil { if err != nil {

View File

@ -3,9 +3,11 @@ package user
import ( import (
"net/http" "net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
) )
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error { func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error {
@ -20,7 +22,12 @@ func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "creating transaction") return errors.Wrap(err, "creating transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
err = s.DB.DeleteUser(ctx, tx, claims.UserID, true, "") err = s.DB.DeleteUser(ctx, tx, claims.UserID, true, "")
if err != nil { if err != nil {

View File

@ -7,6 +7,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
) )
@ -71,7 +72,7 @@ func (s *Server) getExport(w http.ResponseWriter, r *http.Request) error {
} }
log.Errorf("getting export for user %v: %v", claims.UserID, err) log.Errorf("getting export for user %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "getting export")
} }
render.JSON(w, r, dataExportResponse{ render.JSON(w, r, dataExportResponse{

View File

@ -13,6 +13,7 @@ import (
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/rs/xid" "github.com/rs/xid"
) )
@ -80,7 +81,12 @@ func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "starting transaction") return errors.Wrap(err, "starting transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
flag, err := s.DB.CreateFlag(ctx, tx, claims.UserID, req.Name, req.Description) flag, err := s.DB.CreateFlag(ctx, tx, claims.UserID, req.Name, req.Description)
if err != nil { if err != nil {
@ -192,7 +198,12 @@ func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "beginning transaction") return errors.Wrap(err, "beginning transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
flag, err = s.DB.EditFlag(ctx, tx, flag.ID, req.Name, req.Description, nil) flag, err = s.DB.EditFlag(ctx, tx, flag.ID, req.Name, req.Description, nil)
if err != nil { if err != nil {

View File

@ -8,6 +8,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/rs/xid" "github.com/rs/xid"
@ -146,7 +147,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
} }
} else if err != nil { } else if err != nil {
log.Errorf("Error getting user by username: %v", err) log.Errorf("Error getting user by username: %v", err)
return err return errors.Wrap(err, "getting user")
} }
} }
@ -162,13 +163,13 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
fields, err := s.DB.UserFields(ctx, u.ID) fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil { if err != nil {
log.Errorf("Error getting user fields: %v", err) log.Errorf("Error getting user fields: %v", err)
return err return errors.Wrap(err, "getting fields")
} }
flags, err := s.DB.UserFlags(ctx, u.ID) flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil { if err != nil {
log.Errorf("getting user flags: %v", err) log.Errorf("getting user flags: %v", err)
return err return errors.Wrap(err, "getting flags")
} }
var members []db.Member var members []db.Member
@ -176,7 +177,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
members, err = s.DB.UserMembers(ctx, u.ID, isSelf) members, err = s.DB.UserMembers(ctx, u.ID, isSelf)
if err != nil { if err != nil {
log.Errorf("Error getting user members: %v", err) log.Errorf("Error getting user members: %v", err)
return err return errors.Wrap(err, "getting user members")
} }
} }
@ -191,25 +192,25 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
u, err := s.DB.User(ctx, claims.UserID) u, err := s.DB.User(ctx, claims.UserID)
if err != nil { if err != nil {
log.Errorf("Error getting user: %v", err) log.Errorf("Error getting user: %v", err)
return err return errors.Wrap(err, "getting users")
} }
fields, err := s.DB.UserFields(ctx, u.ID) fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil { if err != nil {
log.Errorf("Error getting user fields: %v", err) log.Errorf("Error getting user fields: %v", err)
return err return errors.Wrap(err, "getting fields")
} }
members, err := s.DB.UserMembers(ctx, u.ID, true) members, err := s.DB.UserMembers(ctx, u.ID, true)
if err != nil { if err != nil {
log.Errorf("Error getting user members: %v", err) log.Errorf("Error getting user members: %v", err)
return err return errors.Wrap(err, "getting members")
} }
flags, err := s.DB.UserFlags(ctx, u.ID) flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil { if err != nil {
log.Errorf("getting user flags: %v", err) log.Errorf("getting user flags: %v", err)
return err return errors.Wrap(err, "getting flags")
} }
render.JSON(w, r, GetMeResponse{ render.JSON(w, r, GetMeResponse{

View File

@ -12,6 +12,7 @@ import (
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/rs/xid" "github.com/rs/xid"
) )
@ -195,13 +196,13 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
} }
log.Errorf("converting user avatar: %v", err) log.Errorf("converting user avatar: %v", err)
return err return errors.Wrap(err, "converting avatar")
} }
hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg) hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg)
if err != nil { if err != nil {
log.Errorf("uploading user avatar: %v", err) log.Errorf("uploading user avatar: %v", err)
return err return errors.Wrap(err, "uploading avatar")
} }
avatarHash = &hash avatarHash = &hash
@ -219,9 +220,14 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
tx, err := s.DB.Begin(ctx) tx, err := s.DB.Begin(ctx)
if err != nil { if err != nil {
log.Errorf("creating transaction: %v", err) log.Errorf("creating transaction: %v", err)
return err return errors.Wrap(err, "creating transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
// update username // update username
if req.Username != nil && *req.Username != u.Username { if req.Username != nil && *req.Username != u.Username {
@ -243,7 +249,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.Timezone, req.CustomPreferences) u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.Timezone, req.CustomPreferences)
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate { if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
log.Errorf("updating user: %v", err) log.Errorf("updating user: %v", err)
return err return errors.Wrap(err, "updating user")
} }
if req.Names != nil || req.Pronouns != nil { if req.Names != nil || req.Pronouns != nil {
@ -260,7 +266,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns) err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns)
if err != nil { if err != nil {
log.Errorf("setting names for member %v: %v", claims.UserID, err) log.Errorf("setting names for member %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "setting names/pronouns")
} }
u.Names = names u.Names = names
u.Pronouns = pronouns u.Pronouns = pronouns
@ -271,14 +277,14 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields) err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields)
if err != nil { if err != nil {
log.Errorf("setting fields for user %v: %v", claims.UserID, err) log.Errorf("setting fields for user %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "setting fields")
} }
fields = *req.Fields fields = *req.Fields
} else { } else {
fields, err = s.DB.UserFields(ctx, claims.UserID) fields, err = s.DB.UserFields(ctx, claims.UserID)
if err != nil { if err != nil {
log.Errorf("getting fields for user %v: %v", claims.UserID, err) log.Errorf("getting fields for user %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "getting fields")
} }
} }
@ -291,7 +297,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
} }
log.Errorf("updating flags for user %v: %v", claims.UserID, err) log.Errorf("updating flags for user %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "updating flags")
} }
} }
@ -299,13 +305,13 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil { if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err) log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "updating last active time")
} }
err = tx.Commit(ctx) err = tx.Commit(ctx)
if err != nil { if err != nil {
log.Errorf("committing transaction: %v", err) log.Errorf("committing transaction: %v", err)
return err return errors.Wrap(err, "committing transaction")
} }
// get fedi instance name if the user has a linked fedi account // get fedi instance name if the user has a linked fedi account
@ -321,7 +327,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
flags, err := s.DB.UserFlags(ctx, u.ID) flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil { if err != nil {
log.Errorf("getting user flags: %v", err) log.Errorf("getting user flags: %v", err)
return err return errors.Wrap(err, "getting flags")
} }
// echo the updated user back on success // echo the updated user back on success

View File

@ -5,6 +5,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
) )
@ -13,7 +14,7 @@ func (s *Server) GetSettings(w http.ResponseWriter, r *http.Request) (err error)
u, err := s.DB.User(r.Context(), claims.UserID) u, err := s.DB.User(r.Context(), claims.UserID)
if err != nil { if err != nil {
log.Errorf("getting user: %v", err) log.Errorf("getting user: %v", err)
return err return errors.Wrap(err, "getting user")
} }
render.JSON(w, r, u.Settings) render.JSON(w, r, u.Settings)

View File

@ -1,10 +1,14 @@
package server package server
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
) )
@ -12,6 +16,11 @@ import (
// The inner HandlerFunc additionally returns an error. // The inner HandlerFunc additionally returns an error.
func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc { func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
hub := sentry.GetHubFromContext(r.Context())
if hub == nil {
hub = sentry.CurrentHub().Clone()
}
err := hn(w, r) err := hn(w, r)
if err != nil { if err != nil {
// if the function returned an API error, just render that verbatim // if the function returned an API error, just render that verbatim
@ -24,10 +33,20 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han
return return
} }
// otherwise, we log the error and return an internal server error message rctx := chi.RouteContext(r.Context())
log.Errorf("error in http handler: %v", err) hub.ConfigureScope(func(scope *sentry.Scope) {
scope.SetTag("method", rctx.RouteMethod)
scope.SetTag("path", rctx.RoutePattern())
})
apiErr := APIError{Code: ErrInternalServerError} var eventID *sentry.EventID = nil
if isExpectedError(err) {
log.Infof("expected error in handler for %v %v, ignoring", rctx.RouteMethod, rctx.RoutePattern())
} else {
log.Errorf("error in handler for %v %v: %v", rctx.RouteMethod, rctx.RoutePattern(), err)
eventID = hub.CaptureException(err)
}
apiErr := APIError{ID: eventID, Code: ErrInternalServerError}
apiErr.prepare() apiErr.prepare()
render.Status(r, apiErr.Status) render.Status(r, apiErr.Status)
@ -36,10 +55,15 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han
} }
} }
func isExpectedError(err error) bool {
return errors.Is(err, context.Canceled)
}
// APIError is an object returned by the API when an error occurs. // APIError is an object returned by the API when an error occurs.
// It implements the error interface and can be returned by handlers. // It implements the error interface and can be returned by handlers.
type APIError struct { type APIError struct {
Code int `json:"code"` Code int `json:"code"`
ID *sentry.EventID `json:"id,omitempty"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
Details string `json:"details,omitempty"` Details string `json:"details,omitempty"`

89
backend/server/sentry.go Normal file
View File

@ -0,0 +1,89 @@
package server
import (
"context"
"fmt"
"net/http"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func (s *Server) sentry(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
ctx := r.Context()
hub := sentry.GetHubFromContext(ctx)
if hub == nil {
hub = sentry.CurrentHub().Clone()
ctx = sentry.SetHubOnContext(ctx, hub)
}
options := []sentry.SpanOption{
sentry.WithOpName("http.server"),
sentry.ContinueFromRequest(r),
sentry.WithTransactionSource(sentry.SourceURL),
}
// We don't mind getting an existing transaction back so we don't need to
// check if it is.
transaction := sentry.StartTransaction(ctx,
fmt.Sprintf("%s %s", r.Method, r.URL.Path),
options...,
)
defer transaction.Finish()
r = r.WithContext(transaction.Context())
hub.Scope().SetRequest(r)
defer recoverWithSentry(hub, r)
handler.ServeHTTP(ww, r)
transaction.Status = httpStatusToSentryStatus(ww.Status())
rctx := chi.RouteContext(r.Context())
transaction.Name = rctx.RouteMethod + " " + rctx.RoutePattern()
})
}
func recoverWithSentry(hub *sentry.Hub, r *http.Request) {
if err := recover(); err != nil {
hub.RecoverWithContext(
context.WithValue(r.Context(), sentry.RequestContextKey, r),
err,
)
}
}
func httpStatusToSentryStatus(status int) sentry.SpanStatus {
// c.f. https://develop.sentry.dev/sdk/event-payloads/span/
if status >= 200 && status < 400 {
return sentry.SpanStatusOK
}
switch status {
case 499:
return sentry.SpanStatusCanceled
case 500:
return sentry.SpanStatusInternalError
case 400:
return sentry.SpanStatusInvalidArgument
case 504:
return sentry.SpanStatusDeadlineExceeded
case 404:
return sentry.SpanStatusNotFound
case 409:
return sentry.SpanStatusAlreadyExists
case 403:
return sentry.SpanStatusPermissionDenied
case 429:
return sentry.SpanStatusResourceExhausted
case 501:
return sentry.SpanStatusUnimplemented
case 503:
return sentry.SpanStatusUnavailable
case 401:
return sentry.SpanStatusUnauthenticated
default:
return sentry.SpanStatusUnknown
}
}

View File

@ -50,6 +50,9 @@ func New() (*Server, error) {
s.Router.Use(middleware.Logger) s.Router.Use(middleware.Logger)
} }
s.Router.Use(middleware.Recoverer) s.Router.Use(middleware.Recoverer)
// add Sentry tracing handler
s.Router.Use(s.sentry)
// add CORS // add CORS
s.Router.Use(cors.Handler(cors.Options{ s.Router.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://*", "http://*"}, AllowedOrigins: []string{"https://*", "http://*"},
@ -97,23 +100,23 @@ func New() (*Server, error) {
// set scopes // set scopes
// users // users
rateLimiter.Scope("GET", "/users/*", 60) _ = rateLimiter.Scope("GET", "/users/*", 60)
rateLimiter.Scope("PATCH", "/users/@me", 10) _ = rateLimiter.Scope("PATCH", "/users/@me", 10)
// members // members
rateLimiter.Scope("GET", "/users/*/members", 60) _ = rateLimiter.Scope("GET", "/users/*/members", 60)
rateLimiter.Scope("GET", "/users/*/members/*", 60) _ = rateLimiter.Scope("GET", "/users/*/members/*", 60)
rateLimiter.Scope("POST", "/members", 10) _ = rateLimiter.Scope("POST", "/members", 10)
rateLimiter.Scope("GET", "/members/*", 60) _ = rateLimiter.Scope("GET", "/members/*", 60)
rateLimiter.Scope("PATCH", "/members/*", 20) _ = rateLimiter.Scope("PATCH", "/members/*", 20)
rateLimiter.Scope("DELETE", "/members/*", 5) _ = rateLimiter.Scope("DELETE", "/members/*", 5)
// auth // auth
rateLimiter.Scope("*", "/auth/*", 20) _ = rateLimiter.Scope("*", "/auth/*", 20)
rateLimiter.Scope("*", "/auth/tokens", 10) _ = rateLimiter.Scope("*", "/auth/tokens", 10)
rateLimiter.Scope("*", "/auth/invites", 10) _ = rateLimiter.Scope("*", "/auth/invites", 10)
rateLimiter.Scope("POST", "/auth/discord/*", 10) _ = rateLimiter.Scope("POST", "/auth/discord/*", 10)
s.Router.Use(rateLimiter.Handler()) s.Router.Use(rateLimiter.Handler())

View File

@ -46,8 +46,9 @@ A user can set custom word preferences, which can have custom icons and tooltips
## Pride flag ## Pride flag
| Field | Type | Description | | Field | Type | Description |
| ----------- | ------- | ------------------------------------- | | ----------- | --------- | ------------------------------------- |
| id | string | the flag's unique ID | | id | string | the flag's unique ID |
| id_new | snowflake | the flag's unique snowflake ID |
| hash | string | the flag's [image hash](/api/#images) | | hash | string | the flag's [image hash](/api/#images) |
| name | string | the flag's name | | name | string | the flag's name |
| description | string? | the flag's description or alt text | | description | string? | the flag's description or alt text |

View File

@ -5,6 +5,7 @@
| Field | Type | Description | | Field | Type | Description |
| ------------ | ---------------------------------------------------- | --------------------------------------------------------------------------------- | | ------------ | ---------------------------------------------------- | --------------------------------------------------------------------------------- |
| id | string | the member's unique ID | | id | string | the member's unique ID |
| id_new | snowflake | the member's unique snowflake ID |
| sid | string | the member's 6-letter short ID | | sid | string | the member's 6-letter short ID |
| name | string | the member's name | | name | string | the member's name |
| display_name | string? | the member's display name or nickname | | display_name | string? | the member's display name or nickname |
@ -23,6 +24,7 @@
| Field | Type | Description | | Field | Type | Description |
| ------------------ | ---------------------------------------------------- | -------------------------------------- | | ------------------ | ---------------------------------------------------- | -------------------------------------- |
| id | string | the user's unique ID | | id | string | the user's unique ID |
| id_new | snowflake | the user's unique snowflake ID |
| name | string | the user's username | | name | string | the user's username |
| display_name | string? | the user's display name or nickname | | display_name | string? | the user's display name or nickname |
| avatar | string? | the user's [avatar hash](/api/#images) | | avatar | string? | the user's [avatar hash](/api/#images) |
@ -96,7 +98,7 @@ Returns the updated [member](./members#member-object) on success.
#### Request body parameters #### Request body parameters
| Field | Type | Description | | Field | Type | Description |
| ------------------ | -------------------- | --------------------------------------------------------------------------------------------------- | | ------------ | --------------- | ------------------------------------------------------------------------------------------------------ |
| name | string | the member's new name. Must be unique per user, and be between 1 and 100 characters. | | name | string | the member's new name. Must be unique per user, and be between 1 and 100 characters. |
| display_name | string | the member's new display name. Must be between 1 and 100 characters | | display_name | string | the member's new display name. Must be between 1 and 100 characters |
| bio | string | the member's new bio. Must be between 1 and 1000 characters | | bio | string | the member's new bio. Must be between 1 and 1000 characters |
@ -104,7 +106,7 @@ Returns the updated [member](./members#member-object) on success.
| names | field_entry[] | the member's new preferred names | | names | field_entry[] | the member's new preferred names |
| pronouns | pronoun_entry[] | the member's new preferred pronouns | | pronouns | pronoun_entry[] | the member's new preferred pronouns |
| fields | field[] | the member's new profile fields | | fields | field[] | the member's new profile fields |
| flags | string[] | the member's new flags. This must be an array of [pride flag](./#pride-flag) IDs. | | flags | string[] | the member's new flags. This must be an array of [pride flag](./#pride-flag) IDs, _not_ snowflake IDs. |
| avatar | string | the member's new avatar. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format | | avatar | string | the member's new avatar. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format |
| unlisted | bool | whether or not the member should be hidden from the member list | | unlisted | bool | whether or not the member should be hidden from the member list |

View File

@ -5,6 +5,7 @@
| Field | Type | Description | | Field | Type | Description |
| ------------------ | ---------------------------------------------------- | --------------------------------------------------------------------------- | | ------------------ | ---------------------------------------------------- | --------------------------------------------------------------------------- |
| id | string | the user's unique ID | | id | string | the user's unique ID |
| id_new | snowflake | the user's unique snowflake ID |
| sid | string | the user's 5 letter short ID | | sid | string | the user's 5 letter short ID |
| name | string | the user's username | | name | string | the user's username |
| display_name | string? | the user's display name or nickname | | display_name | string? | the user's display name or nickname |
@ -45,6 +46,7 @@
| Field | Type | Description | | Field | Type | Description |
| ------------ | ----------------------------------- | ---------------------------------------- | | ------------ | ----------------------------------- | ---------------------------------------- |
| id | string | the member's unique ID | | id | string | the member's unique ID |
| id_new | snowflake | the member's unique snowflake ID |
| sid | string | the member's 6-letter short ID | | sid | string | the member's 6-letter short ID |
| name | string | the member's name | | name | string | the member's name |
| display_name | string? | the member's display name or nickname | | display_name | string? | the member's display name or nickname |
@ -89,7 +91,7 @@ Returns the updated [user](./users#user-object) object on success.
| names | field_entry[] | the user's new preferred names | | names | field_entry[] | the user's new preferred names |
| pronouns | pronoun_entry[] | the user's new preferred pronouns | | pronouns | pronoun_entry[] | the user's new preferred pronouns |
| fields | field[] | the user's new profile fields | | fields | field[] | the user's new profile fields |
| flags | string[] | the user's new flags. This must be an array of [pride flag](./#pride-flag) IDs. | | flags | string[] | the user's new flags. This must be an array of [pride flag](./#pride-flag) IDs, _not_ snowflake IDs. |
| avatar | string | the user's new avatar. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format | | avatar | string | the user's new avatar. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format |
| timezone | string | the user's new timezone. Must be in IANA timezone database format | | timezone | string | the user's new timezone. Must be in IANA timezone database format |
| list_private | bool | whether or not the user's member list should be hidden | | list_private | bool | whether or not the user's member list should be hidden |

View File

@ -3,8 +3,9 @@
If there is an error in your request, or the server encounters an error while processing it, an error object will be returned. If there is an error in your request, or the server encounters an error while processing it, an error object will be returned.
| Field | Type | Description | | Field | Type | Description |
| --------------- | ------- | --------------------------------------------------------------- | | --------------- | ------- | ------------------------------------------------------------------- |
| code | int | an [error code](./errors#error-codes) | | code | int | an [error code](./errors#error-codes) |
| id | ?string | an opaque Sentry event ID, only returned for internal server errors |
| message | ?string | a human-readable description of the error | | message | ?string | a human-readable description of the error |
| details | ?string | more details about the error, most often for bad request errors | | details | ?string | more details about the error, most often for bad request errors |
| ratelimit_reset | ?int | the unix time when an expired rate limit will reset | | ratelimit_reset | ?int | the unix time when an expired rate limit will reset |

View File

@ -62,19 +62,23 @@ The "type" column in tables is formatted as follows:
## IDs ## IDs
::: info ### Snowflake IDs
pronouns.cc is [planning a transition](https://codeberg.org/pronounscc/pronouns.cc/issues/89)
to [Snowflake IDs](https://en.wikipedia.org/wiki/Snowflake_ID).
The information below pertains to the current ID format.
:::
The API uses [xid](https://github.com/rs/xid) for unique IDs. These are always serialized as strings. For [multiple reasons](https://codeberg.org/pronounscc/pronouns.cc/issues/89),
pronouns.cc is transitioning to using snowflakes for unique IDs. These will become the default in the next API version,
but are already returned as `id_new` in the relevant objects (users, members, and flags).
### xids
[xid](https://github.com/rs/xid) is the previous unique ID format. These are always serialized as strings.
Although xids have timestamp information embedded in them, this is non-trivial to extract. Although xids have timestamp information embedded in them, this is non-trivial to extract.
xids are unique across _all_ resources, they are never shared (for example, a user and a member cannot share the same ID). xids are unique across _all_ resources, they are never shared (for example, a user and a member cannot share the same ID).
### prns.cc IDs
Users and members also have an additional ID type, `sid`. Users and members also have an additional ID type, `sid`.
These are randomly generated 5 or 6 letter strings, and are used for the prns.cc URL shortener. These are randomly generated 5 or 6 letter strings, and are used for the prns.cc URL shortener.
They can be rerolled once per hour. **These can change at any time**, as short IDs can be rerolled once per hour.
## Images ## Images

View File

@ -17,4 +17,15 @@ module.exports = {
es2017: true, es2017: true,
node: true, node: true,
}, },
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
},
}; };

View File

@ -16,26 +16,22 @@ writeFileSync("src/icons.ts", `const icons = ${output};\nexport default icons;`)
const goCode1 = `// Generated code. DO NOT EDIT const goCode1 = `// Generated code. DO NOT EDIT
package icons package icons
var icons = [...]string{ var icons = map[string]struct{}{
`; `;
const goCode2 = `} const goCode2 = `}
// IsValid returns true if the input is the name of a Bootstrap icon. // IsValid returns true if the input is the name of a Bootstrap icon.
func IsValid(name string) bool { func IsValid(name string) bool {
for i := range icons { _, ok := icons[name]
if icons[i] == name { return ok
return true
}
}
return false
} }
`; `;
let goOutput = goCode1; let goOutput = goCode1;
keys.forEach((element) => { keys.forEach((element) => {
goOutput += ` "${element}",\n`; goOutput += ` "${element}": {},\n`;
}); });
goOutput += goCode2; goOutput += goCode2;

View File

@ -12,11 +12,13 @@
"format": "prettier --plugin-search-dir . --write ." "format": "prettier --plugin-search-dir . --write ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^1.2.3", "@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/kit": "^1.15.0", "@sveltejs/kit": "^2.0.0",
"@types/luxon": "^3.2.2", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/markdown-it": "^12.2.3", "@sveltestrap/sveltestrap": "^6.0.5",
"@types/luxon": "^3.3.7",
"@types/markdown-it": "^13.0.7",
"@types/node": "^18.15.11", "@types/node": "^18.15.11",
"@types/sanitize-html": "^2.9.0", "@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.57.1", "@typescript-eslint/eslint-plugin": "^5.57.1",
@ -25,14 +27,13 @@
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-svelte3": "^4.0.0", "eslint-plugin-svelte3": "^4.0.0",
"prettier": "^2.8.7", "prettier": "^2.8.7",
"prettier-plugin-svelte": "^2.10.0", "prettier-plugin-svelte": "^2.10.1",
"svelte": "^3.58.0", "svelte": "^4.0.0",
"svelte-check": "^3.1.4", "svelte-check": "^3.4.3",
"svelte-hcaptcha": "^0.1.1", "svelte-hcaptcha": "^0.1.1",
"sveltestrap": "^5.10.0",
"tslib": "^2.5.0", "tslib": "^2.5.0",
"typescript": "^4.9.5", "typescript": "^5.0.0",
"vite": "^4.2.1", "vite": "^5.0.0",
"vite-plugin-markdown": "^2.1.0" "vite-plugin-markdown": "^2.1.0"
}, },
"type": "module", "type": "module",
@ -41,8 +42,8 @@
"@popperjs/core": "^2.11.7", "@popperjs/core": "^2.11.7",
"@sentry/node": "^7.46.0", "@sentry/node": "^7.46.0",
"base64-arraybuffer": "^1.0.2", "base64-arraybuffer": "^1.0.2",
"bootstrap": "5.3.0-alpha1", "bootstrap": "^5.3.2",
"bootstrap-icons": "^1.10.4", "bootstrap-icons": "^1.11.2",
"jose": "^4.13.1", "jose": "^4.13.1",
"luxon": "^3.3.0", "luxon": "^3.3.0",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",

19
frontend/src/app.d.ts vendored
View File

@ -16,23 +16,4 @@ declare global {
} }
} }
declare module "svelte-hcaptcha" {
import type { SvelteComponent } from "svelte";
export interface HCaptchaProps {
sitekey?: string;
apihost?: string;
hl?: string;
reCaptchaCompat?: boolean;
theme?: CaptchaTheme;
size?: string;
}
declare class HCaptcha extends SvelteComponent {
$$prop_def: HCaptchaProps;
}
export default HCaptcha;
}
export {}; export {};

View File

@ -2,7 +2,9 @@ import { PRIVATE_SENTRY_DSN } from "$env/static/private";
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
import type { HandleServerError } from "@sveltejs/kit"; import type { HandleServerError } from "@sveltejs/kit";
if (PRIVATE_SENTRY_DSN) {
Sentry.init({ dsn: PRIVATE_SENTRY_DSN }); Sentry.init({ dsn: PRIVATE_SENTRY_DSN });
}
export const handleError = (({ error, event }) => { export const handleError = (({ error, event }) => {
console.log(error); console.log(error);

View File

@ -1,3 +1,4 @@
/* eslint-disable no-unused-vars */
import { PUBLIC_BASE_URL, PUBLIC_MEDIA_URL } from "$env/static/public"; import { PUBLIC_BASE_URL, PUBLIC_MEDIA_URL } from "$env/static/public";
export const MAX_MEMBERS = 500; export const MAX_MEMBERS = 500;

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { NavLink } from "sveltestrap"; import { NavLink } from "@sveltestrap/sveltestrap";
import { page } from "$app/stores"; import { page } from "$app/stores";
export let href: string; export let href: string;

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { APIError } from "$lib/api/entities"; import type { APIError } from "$lib/api/entities";
import { Alert } from "sveltestrap"; import { Alert } from "@sveltestrap/sveltestrap";
export let error: APIError; export let error: APIError;
</script> </script>

View File

@ -4,6 +4,7 @@
export let urls: string[]; export let urls: string[];
export let alt: string; export let alt: string;
export let width = 300; export let width = 300;
export let lazyLoad = false;
const contentTypeFor = (url: string) => { const contentTypeFor = (url: string) => {
if (url.endsWith(".webp")) { if (url.endsWith(".webp")) {
@ -31,6 +32,7 @@
src={urls[0] || defaultAvatars[0]} src={urls[0] || defaultAvatars[0]}
{alt} {alt}
class="rounded-circle img-fluid" class="rounded-circle img-fluid"
loading={lazyLoad ? "lazy" : "eager"}
/> />
</picture> </picture>
{:else} {:else}

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Button, Icon, Tooltip } from "sveltestrap"; import { Button, Icon, Tooltip } from "@sveltestrap/sveltestrap";
export let icon: string; export let icon: string;
export let color: "primary" | "secondary" | "success" | "danger"; export let color: "primary" | "secondary" | "success" | "danger";

View File

@ -6,12 +6,12 @@
type User, type User,
type CustomPreferences, type CustomPreferences,
} from "$lib/api/entities"; } from "$lib/api/entities";
import { Icon, Tooltip } from "sveltestrap"; import { Icon, Tooltip } from "@sveltestrap/sveltestrap";
import FallbackImage from "./FallbackImage.svelte"; import FallbackImage from "./FallbackImage.svelte";
export let user: User; export let user: User;
export let member: PartialMember & { export let member: PartialMember & {
unlisted?: boolean unlisted?: boolean;
}; };
let pronouns: string | undefined; let pronouns: string | undefined;
@ -46,7 +46,12 @@
<div> <div>
<a href="/@{user.name}/{member.name}"> <a href="/@{user.name}/{member.name}">
<FallbackImage urls={memberAvatars(member)} width={200} alt="Avatar for {member.name}" /> <FallbackImage
urls={memberAvatars(member)}
width={200}
lazyLoad
alt="Avatar for {member.name}"
/>
</a> </a>
<p class="m-2"> <p class="m-2">
<a class="text-reset fs-5 text-break" href="/@{user.name}/{member.name}"> <a class="text-reset fs-5 text-break" href="/@{user.name}/{member.name}">

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Icon, Tooltip } from "sveltestrap"; import { Icon, Tooltip } from "@sveltestrap/sveltestrap";
import type { CustomPreference, CustomPreferences } from "$lib/api/entities"; import type { CustomPreference, CustomPreferences } from "$lib/api/entities";
import defaultPreferences from "$lib/api/default_preferences"; import defaultPreferences from "$lib/api/default_preferences";

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Toast } from "sveltestrap"; import { Toast } from "@sveltestrap/sveltestrap";
export let header: string | undefined = undefined; export let header: string | undefined = undefined;
export let body: string; export let body: string;

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Field, CustomPreferences } from "$lib/api/entities"; import type { Field, CustomPreferences } from "$lib/api/entities";
import IconButton from "$lib/components/IconButton.svelte"; import IconButton from "$lib/components/IconButton.svelte";
import { Button, Input, InputGroup } from "sveltestrap"; import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap";
import FieldEntry from "./FieldEntry.svelte"; import FieldEntry from "./FieldEntry.svelte";
export let field: Field; export let field: Field;

View File

@ -9,7 +9,7 @@
DropdownToggle, DropdownToggle,
Icon, Icon,
Tooltip, Tooltip,
} from "sveltestrap"; } from "@sveltestrap/sveltestrap";
export let value: string; export let value: string;
export let status: string; export let status: string;

View File

@ -12,7 +12,7 @@
InputGroupText, InputGroupText,
Popover, Popover,
Tooltip, Tooltip,
} from "sveltestrap"; } from "@sveltestrap/sveltestrap";
export let pronoun: Pronoun; export let pronoun: Pronoun;
export let preferences: CustomPreferences; export let preferences: CustomPreferences;

View File

@ -9,7 +9,7 @@
DropdownToggle, DropdownToggle,
Icon, Icon,
Tooltip, Tooltip,
} from "sveltestrap"; } from "@sveltestrap/sveltestrap";
export let value: string; export let value: string;
export let status: string; export let status: string;

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { flagURL, type PrideFlag } from "$lib/api/entities"; import { flagURL, type PrideFlag } from "$lib/api/entities";
import { Button, Tooltip } from "sveltestrap"; import { Button, Tooltip } from "@sveltestrap/sveltestrap";
export let flag: PrideFlag; export let flag: PrideFlag;
export let tooltip: string; export let tooltip: string;

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Icon, Modal } from "sveltestrap"; import { Icon, Modal } from "@sveltestrap/sveltestrap";
let isOpen = false; let isOpen = false;
const toggle = () => (isOpen = !isOpen); const toggle = () => (isOpen = !isOpen);

View File

@ -13,7 +13,7 @@
import { settingsStore } from "$lib/store"; import { settingsStore } from "$lib/store";
import { toastStore } from "$lib/toast"; import { toastStore } from "$lib/toast";
import Toast from "$lib/components/Toast.svelte"; import Toast from "$lib/components/Toast.svelte";
import { Alert, Icon } from "sveltestrap"; import { Alert, Icon } from "@sveltestrap/sveltestrap";
import { apiFetchClient } from "$lib/api/fetch"; import { apiFetchClient } from "$lib/api/fetch";
import type { Settings } from "$lib/api/entities"; import type { Settings } from "$lib/api/entities";
import { renderUnsafeMarkdown } from "$lib/utils"; import { renderUnsafeMarkdown } from "$lib/utils";
@ -31,6 +31,8 @@
const resp = await apiFetchClient<Settings>( const resp = await apiFetchClient<Settings>(
"/users/@me/settings", "/users/@me/settings",
"PATCH", "PATCH",
// If this function is run, notice will always be non-null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
{ read_global_notice: data.notice!.id }, { read_global_notice: data.notice!.id },
2, 2,
); );

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { PUBLIC_BASE_URL } from "$env/static/public"; import { PUBLIC_BASE_URL } from "$env/static/public";
import { Button } from "sveltestrap"; import { Button } from "@sveltestrap/sveltestrap";
import { userStore } from "$lib/store"; import { userStore } from "$lib/store";
</script> </script>

View File

@ -11,7 +11,7 @@ export const load = async ({ params }) => {
return resp; return resp;
} catch (e) { } catch (e) {
if ((e as APIError).code === ErrorCode.UserNotFound) { if ((e as APIError).code === ErrorCode.UserNotFound) {
throw error(404, e as APIError); error(404, e as App.Error);
} }
throw e; throw e;

View File

@ -4,7 +4,6 @@
import { import {
Alert, Alert,
Badge,
Button, Button,
ButtonGroup, ButtonGroup,
Icon, Icon,
@ -14,8 +13,8 @@
ModalBody, ModalBody,
ModalFooter, ModalFooter,
Tooltip, Tooltip,
} from "sveltestrap"; } from "@sveltestrap/sveltestrap";
import { DateTime, Duration, FixedOffsetZone, Zone } from "luxon"; import { DateTime, FixedOffsetZone } from "luxon";
import FieldCard from "$lib/components/FieldCard.svelte"; import FieldCard from "$lib/components/FieldCard.svelte";
import PronounLink from "$lib/components/PronounLink.svelte"; import PronounLink from "$lib/components/PronounLink.svelte";
import PartialMemberCard from "$lib/components/PartialMemberCard.svelte"; import PartialMemberCard from "$lib/components/PartialMemberCard.svelte";
@ -46,6 +45,7 @@
import ProfileFlag from "./ProfileFlag.svelte"; import ProfileFlag from "./ProfileFlag.svelte";
import IconButton from "$lib/components/IconButton.svelte"; import IconButton from "$lib/components/IconButton.svelte";
import Badges from "./badges/Badges.svelte"; import Badges from "./badges/Badges.svelte";
import PreferencesCheatsheet from "./PreferencesCheatsheet.svelte";
export let data: PageData; export let data: PageData;
@ -190,14 +190,15 @@
{/if} {/if}
{#if data.utc_offset} {#if data.utc_offset}
<Tooltip target="user-clock" placement="top">Current time</Tooltip> <Tooltip target="user-clock" placement="top">Current time</Tooltip>
<Icon id="user-clock" name="clock" aria-label="This user's current time" /> {currentTime} <span class="text-body-secondary">(UTC{timezone})</span> <Icon id="user-clock" name="clock" aria-label="This user's current time" />
{currentTime} <span class="text-body-secondary">(UTC{timezone})</span>
{/if} {/if}
{#if profileEmpty && $userStore?.id === data.id} {#if profileEmpty && $userStore?.id === data.id}
<hr /> <hr />
<p> <p>
<em> <em>
Your profile is empty! You can customize it by going to the <a href="/@{data.name}/edit" Your profile is empty! You can customize it by going to the <a
>edit profile</a href="/@{data.name}/edit">edit profile</a
> page.</em > page.</em
> <span class="text-muted">(only you can see this)</span> > <span class="text-muted">(only you can see this)</span>
</p> </p>
@ -258,6 +259,12 @@
</div> </div>
{/each} {/each}
</div> </div>
<PreferencesCheatsheet
preferences={data.custom_preferences}
names={data.names}
pronouns={data.pronouns}
fields={data.fields}
/>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<InputGroup> <InputGroup>

View File

@ -0,0 +1,65 @@
<script lang="ts">
import type {
CustomPreferences,
CustomPreference,
Field,
FieldEntry,
Pronoun,
} from "$lib/api/entities";
import defaultPreferences from "$lib/api/default_preferences";
import StatusIcon from "$lib/components/StatusIcon.svelte";
export let preferences: CustomPreferences;
export let names: FieldEntry[];
export let pronouns: Pronoun[];
export let fields: Field[];
let mergedPreferences: CustomPreferences;
$: mergedPreferences = Object.assign({}, defaultPreferences, preferences);
// Filter default preferences to the ones the user/member has used
// This is done separately from custom preferences to make the shown list cleaner
let usedDefaultPreferences: Array<{ id: string; preference: CustomPreference }>;
$: usedDefaultPreferences = Object.keys(defaultPreferences)
.filter(
(pref) =>
names.some((entry) => entry.status === pref) ||
pronouns.some((entry) => entry.status === pref) ||
fields.some((field) => field.entries.some((entry) => entry.status === pref)),
)
.map((key) => ({
id: key,
preference: defaultPreferences[key],
}));
// Do the same for custom preferences
let usedCustomPreferences: Array<{ id: string; preference: CustomPreference }>;
$: usedCustomPreferences = Object.keys(preferences)
.filter(
(pref) =>
names.some((entry) => entry.status === pref) ||
pronouns.some((entry) => entry.status === pref) ||
fields.some((field) => field.entries.some((entry) => entry.status === pref)),
)
.map((pref) => ({ id: pref, preference: mergedPreferences[pref] }));
</script>
<div class="text-center">
<ul class="list-inline text-body-secondary">
{#each usedDefaultPreferences as pref (pref.id)}
<li class="list-inline-item mx-2">
<StatusIcon {preferences} status={pref.id} />
{pref.preference.tooltip}
</li>
{/each}
</ul>
{#if usedCustomPreferences}
<ul class="list-inline text-body-secondary">
{#each usedCustomPreferences as pref (pref.id)}
<li class="list-inline-item mx-2">
<StatusIcon {preferences} status={pref.id} />
{pref.preference.tooltip}
</li>
{/each}
</ul>
{/if}
</div>

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { flagURL, type PrideFlag } from "$lib/api/entities"; import { flagURL, type PrideFlag } from "$lib/api/entities";
import { Tooltip } from "sveltestrap"; import { Tooltip } from "@sveltestrap/sveltestrap";
export let flag: PrideFlag; export let flag: PrideFlag;

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Icon } from "sveltestrap"; import { Icon } from "@sveltestrap/sveltestrap";
export let link: string; export let link: string;

View File

@ -3,7 +3,7 @@
import { fastFetchClient } from "$lib/api/fetch"; import { fastFetchClient } from "$lib/api/fetch";
import ErrorAlert from "$lib/components/ErrorAlert.svelte"; import ErrorAlert from "$lib/components/ErrorAlert.svelte";
import { addToast } from "$lib/toast"; import { addToast } from "$lib/toast";
import { Button, FormGroup, Icon, Modal, ModalBody, ModalFooter } from "sveltestrap"; import { Button, FormGroup, Icon, Modal, ModalBody, ModalFooter } from "@sveltestrap/sveltestrap";
export let subject: string; export let subject: string;
export let reportUrl: string; export let reportUrl: string;

View File

@ -14,9 +14,9 @@ export const load = async ({ params }) => {
(e as APIError).code === ErrorCode.UserNotFound || (e as APIError).code === ErrorCode.UserNotFound ||
(e as APIError).code === ErrorCode.MemberNotFound (e as APIError).code === ErrorCode.MemberNotFound
) { ) {
throw error(404, e as APIError); error(404, e as App.Error);
} }
throw error(500, e as APIError); error(500, e as App.Error);
} }
}; };

View File

@ -4,7 +4,7 @@
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import PronounLink from "$lib/components/PronounLink.svelte"; import PronounLink from "$lib/components/PronounLink.svelte";
import FallbackImage from "$lib/components/FallbackImage.svelte"; import FallbackImage from "$lib/components/FallbackImage.svelte";
import { Alert, Button, Icon, InputGroup } from "sveltestrap"; import { Alert, Button, Icon, InputGroup } from "@sveltestrap/sveltestrap";
import { import {
memberAvatars, memberAvatars,
pronounDisplay, pronounDisplay,
@ -22,6 +22,7 @@
import { addToast } from "$lib/toast"; import { addToast } from "$lib/toast";
import ProfileFlag from "../ProfileFlag.svelte"; import ProfileFlag from "../ProfileFlag.svelte";
import IconButton from "$lib/components/IconButton.svelte"; import IconButton from "$lib/components/IconButton.svelte";
import PreferencesCheatsheet from "../PreferencesCheatsheet.svelte";
export let data: PageData; export let data: PageData;
@ -154,6 +155,12 @@
</div> </div>
{/each} {/each}
</div> </div>
<PreferencesCheatsheet
preferences={data.user.custom_preferences}
names={data.names}
pronouns={data.pronouns}
fields={data.fields}
/>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<InputGroup> <InputGroup>

View File

@ -5,7 +5,15 @@
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
import { addToast, delToast } from "$lib/toast"; import { addToast, delToast } from "$lib/toast";
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch"; import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
import { Button, ButtonGroup, Modal, ModalBody, ModalFooter, Nav, NavItem } from "sveltestrap"; import {
Button,
ButtonGroup,
Modal,
ModalBody,
ModalFooter,
Nav,
NavItem,
} from "@sveltestrap/sveltestrap";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import ErrorAlert from "$lib/components/ErrorAlert.svelte"; import ErrorAlert from "$lib/components/ErrorAlert.svelte";
import IconButton from "$lib/components/IconButton.svelte"; import IconButton from "$lib/components/IconButton.svelte";

View File

@ -32,7 +32,7 @@ export const load = (async ({ params }) => {
member.user.name !== params.username || member.user.name !== params.username ||
member.name !== params.memberName member.name !== params.memberName
) { ) {
throw redirect(303, `/@${user.name}/${member.name}`); redirect(303, `/@${user.name}/${member.name}`);
} }
return { return {
@ -41,8 +41,9 @@ export const load = (async ({ params }) => {
pronouns: pronouns.autocomplete, pronouns: pronouns.autocomplete,
flags, flags,
}; };
} catch (e) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
if ("code" in e) throw error(500, e as APIError); } catch (e: any) {
if ("code" in e) error(500, e as App.Error);
throw e; throw e;
} }
}) satisfies LayoutLoad; }) satisfies LayoutLoad;

View File

@ -3,7 +3,7 @@
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { encode } from "base64-arraybuffer"; import { encode } from "base64-arraybuffer";
import { FormGroup, Icon, Input } from "sveltestrap"; import { FormGroup, Icon, Input } from "@sveltestrap/sveltestrap";
import { memberAvatars, type Member } from "$lib/api/entities"; import { memberAvatars, type Member } from "$lib/api/entities";
import FallbackImage from "$lib/components/FallbackImage.svelte"; import FallbackImage from "$lib/components/FallbackImage.svelte";
import EditableName from "$lib/components/edit/EditableName.svelte"; import EditableName from "$lib/components/edit/EditableName.svelte";

View File

@ -4,7 +4,7 @@
import { MAX_DESCRIPTION_LENGTH, type Member } from "$lib/api/entities"; import { MAX_DESCRIPTION_LENGTH, type Member } from "$lib/api/entities";
import { charCount, renderMarkdown } from "$lib/utils"; import { charCount, renderMarkdown } from "$lib/utils";
import MarkdownHelp from "$lib/components/edit/MarkdownHelp.svelte"; import MarkdownHelp from "$lib/components/edit/MarkdownHelp.svelte";
import { Card, CardBody, CardHeader } from "sveltestrap"; import { Card, CardBody, CardHeader } from "@sveltestrap/sveltestrap";
const member = getContext<Writable<Member>>("member"); const member = getContext<Writable<Member>>("member");
</script> </script>

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { Button, Icon } from "sveltestrap"; import { Button, Icon } from "@sveltestrap/sveltestrap";
import type { Member } from "$lib/api/entities"; import type { Member } from "$lib/api/entities";
import EditableField from "$lib/components/edit/EditableField.svelte"; import EditableField from "$lib/components/edit/EditableField.svelte";
@ -45,9 +45,7 @@
</div> </div>
</div> </div>
<div> <div>
<Button <Button on:click={() => ($member.fields = [...$member.fields, { name: null, entries: [] }])}>
on:click={() => ($member.fields = [...$member.fields, { name: null, entries: [] }])}
>
<Icon name="plus" aria-hidden /> Add new field <Icon name="plus" aria-hidden /> Add new field
</Button> </Button>
</div> </div>

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { Alert, ButtonGroup, Input } from "sveltestrap"; import { Alert, ButtonGroup, Input } from "@sveltestrap/sveltestrap";
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import type { Member, PrideFlag } from "$lib/api/entities"; import type { Member, PrideFlag } from "$lib/api/entities";

View File

@ -2,7 +2,7 @@
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { Button, ButtonGroup, Icon } from "sveltestrap"; import { Button, ButtonGroup, Icon } from "@sveltestrap/sveltestrap";
import type { APIError, Member } from "$lib/api/entities"; import type { APIError, Member } from "$lib/api/entities";
import { PUBLIC_SHORT_BASE } from "$env/static/public"; import { PUBLIC_SHORT_BASE } from "$env/static/public";

View File

@ -2,7 +2,7 @@
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import type { Member } from "$lib/api/entities"; import type { Member } from "$lib/api/entities";
import { Button, Icon, Popover } from "sveltestrap"; import { Button, Icon, Popover } from "@sveltestrap/sveltestrap";
import EditablePronouns from "$lib/components/edit/EditablePronouns.svelte"; import EditablePronouns from "$lib/components/edit/EditablePronouns.svelte";
import IconButton from "$lib/components/IconButton.svelte"; import IconButton from "$lib/components/IconButton.svelte";
import type { PageData } from "./$types"; import type { PageData } from "./$types";

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Tooltip } from "sveltestrap"; import { Tooltip } from "@sveltestrap/sveltestrap";
let icon: HTMLElement; let icon: HTMLElement;
</script> </script>

View File

@ -2,7 +2,7 @@
import { setContext } from "svelte"; import { setContext } from "svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
import { Button, ButtonGroup, Icon, Nav, NavItem } from "sveltestrap"; import { Button, ButtonGroup, Icon, Nav, NavItem } from "@sveltestrap/sveltestrap";
import type { MeUser, APIError } from "$lib/api/entities"; import type { MeUser, APIError } from "$lib/api/entities";
import ErrorAlert from "$lib/components/ErrorAlert.svelte"; import ErrorAlert from "$lib/components/ErrorAlert.svelte";
import { addToast, delToast } from "$lib/toast"; import { addToast, delToast } from "$lib/toast";

View File

@ -1,6 +1,6 @@
import type { PrideFlag, APIError, MeUser, PronounsJson } from "$lib/api/entities"; import type { PrideFlag, MeUser, PronounsJson } from "$lib/api/entities";
import { apiFetchClient } from "$lib/api/fetch"; import { apiFetchClient } from "$lib/api/fetch";
import { error, redirect, type Redirect } from "@sveltejs/kit"; import { error, redirect } from "@sveltejs/kit";
import pronounsRaw from "$lib/pronouns.json"; import pronounsRaw from "$lib/pronouns.json";
const pronouns = pronounsRaw as PronounsJson; const pronouns = pronounsRaw as PronounsJson;
@ -13,7 +13,7 @@ export const load = async ({ params }) => {
const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags"); const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags");
if (params.username !== user.name) { if (params.username !== user.name) {
throw redirect(303, `/@${user.name}/edit`); redirect(303, `/@${user.name}/edit`);
} }
return { return {
@ -21,8 +21,9 @@ export const load = async ({ params }) => {
pronouns: pronouns.autocomplete, pronouns: pronouns.autocomplete,
flags, flags,
}; };
} catch (e) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
if ("code" in e) throw error(500, e as APIError); } catch (e: any) {
if ("code" in e) error(500, e as App.Error);
throw e; throw e;
} }
}; };

View File

@ -3,7 +3,7 @@
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { encode } from "base64-arraybuffer"; import { encode } from "base64-arraybuffer";
import { FormGroup, Icon, Input } from "sveltestrap"; import { FormGroup, Icon, Input } from "@sveltestrap/sveltestrap";
import { userAvatars, type MeUser } from "$lib/api/entities"; import { userAvatars, type MeUser } from "$lib/api/entities";
import FallbackImage from "$lib/components/FallbackImage.svelte"; import FallbackImage from "$lib/components/FallbackImage.svelte";
import EditableName from "$lib/components/edit/EditableName.svelte"; import EditableName from "$lib/components/edit/EditableName.svelte";

View File

@ -4,7 +4,7 @@
import { MAX_DESCRIPTION_LENGTH, type MeUser } from "$lib/api/entities"; import { MAX_DESCRIPTION_LENGTH, type MeUser } from "$lib/api/entities";
import { charCount, renderMarkdown } from "$lib/utils"; import { charCount, renderMarkdown } from "$lib/utils";
import MarkdownHelp from "$lib/components/edit/MarkdownHelp.svelte"; import MarkdownHelp from "$lib/components/edit/MarkdownHelp.svelte";
import { Card, CardBody, CardHeader } from "sveltestrap"; import { Card, CardBody, CardHeader } from "@sveltestrap/sveltestrap";
const user = getContext<Writable<MeUser>>("user"); const user = getContext<Writable<MeUser>>("user");
</script> </script>

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { Button, Icon } from "sveltestrap"; import { Button, Icon } from "@sveltestrap/sveltestrap";
import type { MeUser } from "$lib/api/entities"; import type { MeUser } from "$lib/api/entities";
import EditableField from "$lib/components/edit/EditableField.svelte"; import EditableField from "$lib/components/edit/EditableField.svelte";

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { Alert, ButtonGroup, Input } from "sveltestrap"; import { Alert, ButtonGroup, Input } from "@sveltestrap/sveltestrap";
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import type { MeUser, PrideFlag } from "$lib/api/entities"; import type { MeUser, PrideFlag } from "$lib/api/entities";

View File

@ -4,7 +4,14 @@
import { PreferenceSize, type APIError, type MeUser } from "$lib/api/entities"; import { PreferenceSize, type APIError, type MeUser } from "$lib/api/entities";
import IconButton from "$lib/components/IconButton.svelte"; import IconButton from "$lib/components/IconButton.svelte";
import { Button, ButtonGroup, FormGroup, Icon, Input, InputGroup } from "sveltestrap"; import {
Button,
ButtonGroup,
FormGroup,
Icon,
Input,
InputGroup,
} from "@sveltestrap/sveltestrap";
import { PUBLIC_SHORT_BASE } from "$env/static/public"; import { PUBLIC_SHORT_BASE } from "$env/static/public";
import CustomPreference from "./CustomPreference.svelte"; import CustomPreference from "./CustomPreference.svelte";
import { DateTime, FixedOffsetZone } from "luxon"; import { DateTime, FixedOffsetZone } from "luxon";

View File

@ -9,7 +9,7 @@
Icon, Icon,
InputGroup, InputGroup,
Tooltip, Tooltip,
} from "sveltestrap"; } from "@sveltestrap/sveltestrap";
import IconPicker from "./IconPicker.svelte"; import IconPicker from "./IconPicker.svelte";
export let preference: CustomPreference; export let preference: CustomPreference;

View File

@ -7,7 +7,7 @@
Icon, Icon,
Input, Input,
Tooltip, Tooltip,
} from "sveltestrap"; } from "@sveltestrap/sveltestrap";
import icons from "../../../../icons"; import icons from "../../../../icons";
import IconButton from "$lib/components/IconButton.svelte"; import IconButton from "$lib/components/IconButton.svelte";

View File

@ -2,7 +2,7 @@
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import type { MeUser } from "$lib/api/entities"; import type { MeUser } from "$lib/api/entities";
import { Button, Icon, Popover } from "sveltestrap"; import { Button, Icon, Popover } from "@sveltestrap/sveltestrap";
import EditablePronouns from "$lib/components/edit/EditablePronouns.svelte"; import EditablePronouns from "$lib/components/edit/EditablePronouns.svelte";
import IconButton from "$lib/components/IconButton.svelte"; import IconButton from "$lib/components/IconButton.svelte";
import type { PageData } from "./$types"; import type { PageData } from "./$types";

View File

@ -8,10 +8,10 @@ export const load = async ({ params }) => {
method: "GET", method: "GET",
}); });
throw redirect(303, `/@${resp.name}`); redirect(303, `/@${resp.name}`);
} catch (e) { } catch (e) {
if ((e as APIError).code === ErrorCode.UserNotFound) { if ((e as APIError).code === ErrorCode.UserNotFound) {
throw error(404, e as APIError); error(404, e as App.Error);
} }
throw e; throw e;

View File

@ -15,7 +15,7 @@
Modal, Modal,
ModalBody, ModalBody,
ModalFooter, ModalFooter,
} from "sveltestrap"; } from "@sveltestrap/sveltestrap";
import type { PageData } from "./$types"; import type { PageData } from "./$types";
export let data: PageData; export let data: PageData;

View File

@ -19,7 +19,7 @@
Modal, Modal,
ModalBody, ModalBody,
ModalFooter, ModalFooter,
} from "sveltestrap"; } from "@sveltestrap/sveltestrap";
export let authType: string; export let authType: string;
export let remoteName: string | undefined; export let remoteName: string | undefined;
@ -64,8 +64,10 @@
) => Promise<void>; ) => Promise<void>;
let captchaToken = ""; let captchaToken = "";
// svelte-hcaptcha doesn't have types, so we can't use anything except `any` here.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let captcha: any; let captcha: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const captchaSuccess = (token: any) => { const captchaSuccess = (token: any) => {
captchaToken = token.detail.token; captchaToken = token.detail.token;
}; };
@ -88,6 +90,8 @@
await fastFetch("/auth/force-delete", { await fastFetch("/auth/force-delete", {
method: "GET", method: "GET",
headers: { headers: {
// We know for sure this value is non-null if this function is run at all
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
"X-Delete-Token": token!, "X-Delete-Token": token!,
}, },
}); });
@ -105,6 +109,8 @@
await fastFetch("/auth/cancel-delete", { await fastFetch("/auth/cancel-delete", {
method: "GET", method: "GET",
headers: { headers: {
// We know for sure this value is non-null if this function is run at all
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
"X-Delete-Token": token!, "X-Delete-Token": token!,
}, },
}); });

View File

@ -10,7 +10,7 @@
export let data: PageData; export let data: PageData;
let callbackPage: any; let callbackPage: CallbackPage;
const signupForm = async (username: string, invite: string, captchaToken: string) => { const signupForm = async (username: string, invite: string, captchaToken: string) => {
try { try {

View File

@ -10,7 +10,7 @@
export let data: PageData; export let data: PageData;
let callbackPage: any; let callbackPage: CallbackPage;
const signupForm = async (username: string, invite: string, captchaToken: string) => { const signupForm = async (username: string, invite: string, captchaToken: string) => {
try { try {

View File

@ -10,7 +10,7 @@
export let data: PageData; export let data: PageData;
let callbackPage: any; let callbackPage: CallbackPage;
const signupForm = async (username: string, invite: string, captchaToken: string) => { const signupForm = async (username: string, invite: string, captchaToken: string) => {
try { try {

View File

@ -10,7 +10,7 @@
export let data: PageData; export let data: PageData;
let callbackPage: any; let callbackPage: CallbackPage;
const signupForm = async (username: string, invite: string, captchaToken: string) => { const signupForm = async (username: string, invite: string, captchaToken: string) => {
try { try {

View File

@ -10,7 +10,7 @@
export let data: PageData; export let data: PageData;
let callbackPage: any; let callbackPage: CallbackPage;
const signupForm = async (username: string, invite: string, captchaToken: string) => { const signupForm = async (username: string, invite: string, captchaToken: string) => {
try { try {

View File

@ -16,14 +16,14 @@ export const load = async ({ params }) => {
} as APIError; } as APIError;
} }
throw redirect(303, `/@${user.name}/${member.name}/edit`); redirect(303, `/@${user.name}/${member.name}/edit`);
} catch (e) { } catch (e) {
if ( if (
(e as APIError).code === ErrorCode.Forbidden || (e as APIError).code === ErrorCode.Forbidden ||
(e as APIError).code === ErrorCode.InvalidToken || (e as APIError).code === ErrorCode.InvalidToken ||
(e as APIError).code === ErrorCode.NotOwnMember (e as APIError).code === ErrorCode.NotOwnMember
) { ) {
throw error(403, e as APIError); error(403, e as App.Error);
} }
throw e; throw e;

View File

@ -8,13 +8,13 @@ export const load = async () => {
try { try {
const resp = await apiFetchClient<MeUser>(`/users/@me`); const resp = await apiFetchClient<MeUser>(`/users/@me`);
throw redirect(303, `/@${resp.name}/edit`); redirect(303, `/@${resp.name}/edit`);
} catch (e) { } catch (e) {
if ( if (
(e as APIError).code === ErrorCode.Forbidden || (e as APIError).code === ErrorCode.Forbidden ||
(e as APIError).code === ErrorCode.InvalidToken (e as APIError).code === ErrorCode.InvalidToken
) { ) {
throw error(403, e as APIError); error(403, e as App.Error);
} }
throw e; throw e;

View File

@ -14,7 +14,7 @@
NavbarToggler, NavbarToggler,
NavItem, NavItem,
NavLink, NavLink,
} from "sveltestrap"; } from "@sveltestrap/sveltestrap";
import Logo from "./Logo.svelte"; import Logo from "./Logo.svelte";
import { userStore, themeStore, CURRENT_CHANGELOG, settingsStore } from "$lib/store"; import { userStore, themeStore, CURRENT_CHANGELOG, settingsStore } from "$lib/store";

View File

@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
// Ignoring the TS error here, because this file imports fine, typescript just chokes on markdown files
// eslint-disable-next-line
//@ts-ignore
import { html } from "./about.md"; import { html } from "./about.md";
</script> </script>

View File

@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
// Ignoring the TS error here, because this file imports fine, typescript just chokes on markdown files
// eslint-disable-next-line
//@ts-ignore
import { html } from "./changelog.md"; import { html } from "./changelog.md";
import { CURRENT_CHANGELOG } from "$lib/store"; import { CURRENT_CHANGELOG } from "$lib/store";

View File

@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
// Ignoring the TS error here, because this file imports fine, typescript just chokes on markdown files
// eslint-disable-next-line
//@ts-ignore
import { html } from "./privacy.md"; import { html } from "./privacy.md";
</script> </script>

View File

@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
// Ignoring the TS error here, because this file imports fine, typescript just chokes on markdown files
// eslint-disable-next-line
//@ts-ignore
import { html } from "./terms.md"; import { html } from "./terms.md";
</script> </script>

Some files were not shown because too many files have changed in this diff Show More