Compare commits

..

No commits in common. "main" and "feature/notices" have entirely different histories.

137 changed files with 3721 additions and 6481 deletions

View file

@ -6,7 +6,7 @@ tmp_dir = "tmp"
bin = "./tmp/main" bin = "./tmp/main"
cmd = "go build -o ./tmp/main ." cmd = "go build -o ./tmp/main ."
delay = 1000 delay = 1000
exclude_dir = ["docs", "frontend", "prns", "pronounslib", "tmp", "target", "node_modules"] exclude_dir = ["docs", "frontend", "prns", "pronounslib", "tmp", "target"]
exclude_file = [] exclude_file = []
exclude_regex = ["_test.go"] exclude_regex = ["_test.go"]
exclude_unchanged = false exclude_unchanged = false

1
.gitignore vendored
View file

@ -13,4 +13,3 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
target target
tmp tmp
seed.yaml

View file

@ -1,13 +0,0 @@
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

View file

@ -1,20 +0,0 @@
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 --always`" . 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`" .
.PHONY: generate .PHONY: generate
generate: generate:

View file

@ -26,25 +26,19 @@ Requirements:
- Redis 6.0 or later - Redis 6.0 or later
- Node.js (latest version) - Node.js (latest version)
- MinIO **if using avatars, flags, or data exports** (_not_ required otherwise) - MinIO **if using avatars, flags, or data exports** (_not_ required otherwise)
- [Air](https://github.com/cosmtrek/air) for live reloading the backend
### Setup ### Setup
1. Create a PostgreSQL user and database (the user should own the database). 1. Create a PostgreSQL user and database (the user should own the database).
For example: `create user pronouns with password 'password'; create database pronouns with owner pronouns;` For example: `create user pronouns with password 'password'; create database pronouns with owner pronouns;`
2. Copy `.env.example` in the repository root to a new file named `.env` and fill out the required options. 2. Copy `.env.example` in the repository root to a new file named `.env` and fill out the required options.
3. Copy `frontend/.env.example` to `frontend/env` and fill out the required options. 3. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
4. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user. 4. Run `go run -v . web` to run the backend.
5. Run `pnpm dev`. Alternatively, if you don't want the backend to live reload, run `go run -v . web`, 5. Copy `frontend/.env.example` into `frontend/.env` and tweak as necessary.
then change to the `frontend/` directory and run `pnpm dev`. 6. cd into the `frontend` directory and run `pnpm dev` to run the frontend.
See [`docs/production.md`](/docs/production.md#configuration) for more information about keys in the backend and frontend `.env` files. See [`docs/production.md`](/docs/production.md#configuration) for more information about keys in the backend and frontend `.env` files.
### Seeding
To seed the database with some data, create a `seed.yaml` file, then use `go run -v . database seed`.
For the file format, refer to the `Seed` struct in `scripts/seeddb`.
## License ## License
Copyright (C) 2022 Sam <u1f320> Copyright (C) 2022 Sam <u1f320>

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")
} }
err = pgxscan.Get(ctx, db, &de, sql, args...) 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

@ -48,11 +48,11 @@ func (f FediverseApp) ClientConfig() *oauth2.Config {
} }
func (f FediverseApp) MastodonCompatible() bool { func (f FediverseApp) MastodonCompatible() bool {
return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "incestoma" || f.InstanceType == "pixelfed" || f.InstanceType == "gotosocial" return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "pixelfed" || f.InstanceType == "gotosocial"
} }
func (f FediverseApp) Misskey() bool { func (f FediverseApp) Misskey() bool {
return f.InstanceType == "misskey" || f.InstanceType == "foundkey" || f.InstanceType == "calckey" || f.InstanceType == "firefish" || f.InstanceType == "sharkey" return f.InstanceType == "misskey" || f.InstanceType == "foundkey" || f.InstanceType == "calckey" || f.InstanceType == "firefish"
} }
const ErrNoInstanceApp = errors.Sentinel("instance doesn't have an app") const ErrNoInstanceApp = errors.Sentinel("instance doesn't have an app")

View file

@ -43,10 +43,7 @@ func (f Field) Validate(custom CustomPreferences) string {
} }
if !entry.Status.Valid(custom) { if !entry.Status.Valid(custom) {
if entry.Status == "missing" { return fmt.Sprintf("entries.%d: status is invalid", i)
return fmt.Sprintf("didn't select a status for entries.%d. make sure to select it to the right of the field", i)
}
return fmt.Sprintf("entries.%d status: '%s' is invalid", i, entry.Status)
} }
} }

View file

@ -6,7 +6,6 @@ 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"
@ -44,12 +43,7 @@ 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 func() { defer tx.Rollback(ctx)
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,7 +7,6 @@ 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"
@ -42,14 +41,12 @@ 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",
} }
@ -288,12 +285,7 @@ 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 func() { defer tx.Rollback(ctx)
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()")).

View file

@ -825,24 +825,3 @@ func (db *DB) CleanUser(ctx context.Context, id xid.ID) error {
} }
return nil return nil
} }
const inactiveUsersSQL = `select id, snowflake_id from users
where last_active < now() - '30 days'::interval
and display_name is null and bio is null and timezone is null
and links is null and avatar is null and member_title is null
and names = '[]' and pronouns = '[]'
and (select count(m.id) from members m where user_id = users.id) = 0
and (select count(f.id) from user_fields f where user_id = users.id) = 0;`
// InactiveUsers gets the list of inactive users from the database.
// "Inactive" is defined as:
// - not logged in for 30 days or more
// - no display name, bio, avatar, names, pronouns, profile links, or profile fields
// - no members
func (db *DB) InactiveUsers(ctx context.Context, tx pgx.Tx) (us []User, err error) {
err = pgxscan.Select(ctx, tx, &us, inactiveUsersSQL)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return us, nil
}

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,6 @@ 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"
@ -24,19 +23,6 @@ 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,6 +2,7 @@ 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"
@ -20,6 +21,7 @@ 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,7 +11,6 @@ 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"
@ -62,7 +61,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 errors.Wrap(err, "validating state") return err
} }
return server.APIError{Code: server.ErrInvalidState} return server.APIError{Code: server.ErrInvalidState}
@ -80,7 +79,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 errors.Wrap(err, "getting discord user") return err
} }
u, err := s.DB.DiscordUser(ctx, du.ID) u, err := s.DB.DiscordUser(ctx, du.ID)
@ -91,7 +90,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 errors.Wrap(err, "saving undelete token") return err
} }
render.JSON(w, r, discordCallbackResponse{ render.JSON(w, r, discordCallbackResponse{
@ -115,7 +114,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 errors.Wrap(err, "creating token") return err
} }
// save token to database // save token to database
@ -138,7 +137,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 errors.Wrap(err, "getting user") return err
} }
// 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
@ -146,7 +145,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 errors.Wrap(err, "caching discord user for ticket") return err
} }
render.JSON(w, r, discordCallbackResponse{ render.JSON(w, r, discordCallbackResponse{
@ -279,7 +278,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 errors.Wrap(err, "checking if username is taken") return err
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -292,12 +291,7 @@ 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 func() { defer tx.Rollback(ctx)
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,7 +11,6 @@ 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"
) )
@ -55,7 +54,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 errors.Wrap(err, "validating state") return err
} }
return server.APIError{Code: server.ErrInvalidState} return server.APIError{Code: server.ErrInvalidState}
@ -112,7 +111,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 errors.Wrap(err, "saving undelete token") return err
} }
render.JSON(w, r, fediCallbackResponse{ render.JSON(w, r, fediCallbackResponse{
@ -136,7 +135,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 errors.Wrap(err, "creating token") return err
} }
// save token to database // save token to database
@ -159,7 +158,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 errors.Wrap(err, "getting user") return err
} }
// 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
@ -167,7 +166,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 errors.Wrap(err, "setting user for ticket") return err
} }
render.JSON(w, r, fediCallbackResponse{ render.JSON(w, r, fediCallbackResponse{
@ -307,7 +306,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 errors.Wrap(err, "checking if username is taken") return err
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -320,12 +319,7 @@ 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 func() { defer tx.Rollback(ctx)
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,7 +12,6 @@ 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"
) )
@ -91,7 +90,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 errors.Wrap(err, "saving undelete token") return err
} }
render.JSON(w, r, fediCallbackResponse{ render.JSON(w, r, fediCallbackResponse{
@ -115,7 +114,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 errors.Wrap(err, "creating token") return err
} }
// save token to database // save token to database
@ -138,7 +137,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 errors.Wrap(err, "getting user") return err
} }
// 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
@ -146,7 +145,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 errors.Wrap(err, "setting user for ticket") return err
} }
render.JSON(w, r, fediCallbackResponse{ render.JSON(w, r, fediCallbackResponse{
@ -235,7 +234,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 errors.Wrap(err, "checking if username is taken") return err
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -248,12 +247,7 @@ 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 func() { defer tx.Rollback(ctx)
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

@ -65,13 +65,13 @@ func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r
} }
switch softwareName { switch softwareName {
case "iceshrimp": case "misskey", "foundkey", "calckey", "firefish":
softwareName = "firefish"
fallthrough
case "misskey", "foundkey", "calckey", "firefish", "sharkey":
return s.noAppMisskeyURL(ctx, w, r, softwareName, instance) return s.noAppMisskeyURL(ctx, w, r, softwareName, instance)
case "mastodon", "pleroma", "akkoma", "incestoma", "pixelfed", "gotosocial": case "mastodon", "pleroma", "akkoma", "pixelfed", "gotosocial":
case "glitchcafe", "hometown": case "glitchcafe", "hometown":
// plural.cafe (potentially other instances too?) runs Mastodon but changes the software name
// Hometown is a lightweight fork of Mastodon so we can just treat it the same
// changing it back to mastodon here for consistency
softwareName = "mastodon" softwareName = "mastodon"
default: default:
return server.APIError{Code: server.ErrUnsupportedInstance} return server.APIError{Code: server.ErrUnsupportedInstance}

View file

@ -10,7 +10,6 @@ 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"
@ -61,7 +60,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 errors.Wrap(err, "validating state") return err
} }
return server.APIError{Code: server.ErrInvalidState} return server.APIError{Code: server.ErrInvalidState}
@ -110,7 +109,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 errors.Wrap(err, "saving undelete token") return err
} }
render.JSON(w, r, googleCallbackResponse{ render.JSON(w, r, googleCallbackResponse{
@ -134,7 +133,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 errors.Wrap(err, "creating token") return err
} }
// save token to database // save token to database
@ -157,7 +156,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 errors.Wrap(err, "getting user") return err
} }
// 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
@ -165,7 +164,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 errors.Wrap(err, "setting user for ticket") return err
} }
render.JSON(w, r, googleCallbackResponse{ render.JSON(w, r, googleCallbackResponse{
@ -282,7 +281,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 errors.Wrap(err, "checking if username is taken") return err
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -295,12 +294,7 @@ 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 func() { defer tx.Rollback(ctx)
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) + "&prompt=select_account" resp.Google = googleCfg.AuthCodeURL(state)
} }
render.JSON(w, r, resp) render.JSON(w, r, resp)

View file

@ -5,11 +5,9 @@ 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"
) )
@ -65,12 +63,7 @@ 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 func() { defer tx.Rollback(ctx)
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,7 +12,6 @@ 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"
@ -78,7 +77,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 errors.Wrap(err, "validating state") return err
} }
return server.APIError{Code: server.ErrInvalidState} return server.APIError{Code: server.ErrInvalidState}
@ -143,7 +142,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 errors.Wrap(err, "saving undelete token") return err
} }
render.JSON(w, r, tumblrCallbackResponse{ render.JSON(w, r, tumblrCallbackResponse{
@ -167,7 +166,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 errors.Wrap(err, "creating token") return err
} }
// save token to database // save token to database
@ -190,7 +189,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 errors.Wrap(err, "getting user") return err
} }
// 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
@ -198,7 +197,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 errors.Wrap(err, "setting user for ticket") return err
} }
render.JSON(w, r, tumblrCallbackResponse{ render.JSON(w, r, tumblrCallbackResponse{
@ -315,7 +314,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 errors.Wrap(err, "checking if username is taken") return err
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -328,12 +327,7 @@ 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 func() { defer tx.Rollback(ctx)
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

@ -0,0 +1,183 @@
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,7 +11,6 @@ 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 {
@ -120,12 +119,7 @@ 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 func() { defer tx.Rollback(ctx)
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 {
@ -133,14 +127,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 errors.Wrap(err, "creating member") return err
} }
// 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 errors.Wrap(err, "setting names/pronouns") return err
} }
m.Names = cmr.Names m.Names = cmr.Names
m.Pronouns = cmr.Pronouns m.Pronouns = cmr.Pronouns
@ -148,7 +142,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 errors.Wrap(err, "setting fields") return err
} }
if cmr.Avatar != "" { if cmr.Avatar != "" {
@ -167,13 +161,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 errors.Wrap(err, "converting avatar") return err
} }
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 errors.Wrap(err, "uploading avatar") return err
} }
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)
@ -186,7 +180,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 errors.Wrap(err, "updating last active time") return err
} }
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 errors.Wrap(err, "updating last active time") return err
} }
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 errors.Wrap(err, "getting user") return err
} }
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 errors.Wrap(err, "getting member fields") return err
} }
flags, err := s.DB.MemberFlags(ctx, m.ID) flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil { if err != nil {
return errors.Wrap(err, "getting member flags") return err
} }
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 errors.Wrap(err, "getting member fields") return err
} }
flags, err := s.DB.MemberFlags(ctx, m.ID) flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil { if err != nil {
return errors.Wrap(err, "getting member flags") return err
} }
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 errors.Wrap(err, "getting member fields") return err
} }
flags, err := s.DB.MemberFlags(ctx, m.ID) flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil { if err != nil {
return errors.Wrap(err, "getting member flags") return err
} }
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true)) render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))

View file

@ -6,7 +6,6 @@ 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"
@ -75,7 +74,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 errors.Wrap(err, "getting members") return err
} }
render.JSON(w, r, membersToMemberList(ms, isSelf)) render.JSON(w, r, membersToMemberList(ms, isSelf))
@ -88,7 +87,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 errors.Wrap(err, "getting members") return err
} }
render.JSON(w, r, membersToMemberList(ms, true)) render.JSON(w, r, membersToMemberList(ms, true))

View file

@ -13,7 +13,6 @@ 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"
) )
@ -221,13 +220,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 errors.Wrap(err, "converting member avatar") return err
} }
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 errors.Wrap(err, "writing member avatar") return err
} }
avatarHash = &hash avatarHash = &hash
@ -245,14 +244,9 @@ 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 errors.Wrap(err, "creating transaction") return err
} }
defer func() { defer tx.Rollback(ctx)
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 {
@ -281,7 +275,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 errors.Wrap(err, "setting names/pronouns") return err
} }
m.Names = names m.Names = names
m.Pronouns = pronouns m.Pronouns = pronouns
@ -292,14 +286,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 errors.Wrap(err, "setting fields") return err
} }
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 errors.Wrap(err, "getting fields") return err
} }
} }
@ -312,7 +306,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 errors.Wrap(err, "updating flags") return err
} }
} }
@ -320,20 +314,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 errors.Wrap(err, "updating last active time") return err
} }
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 errors.Wrap(err, "committing transaction") return err
} }
// 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 errors.Wrap(err, "getting flags") return err
} }
// echo the updated member back on success // echo the updated member back on success

View file

@ -49,7 +49,7 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
var notice *MetaNotice var notice *MetaNotice
if n, err := s.DB.CurrentNotice(ctx); err != nil { if n, err := s.DB.CurrentNotice(ctx); err != nil {
if err != db.ErrNoNotice { if err == db.ErrNoNotice {
log.Errorf("getting notice: %v", err) log.Errorf("getting notice: %v", err)
} }
} else { } else {

View file

@ -10,7 +10,6 @@ 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 {
@ -44,12 +43,7 @@ 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 func() { defer tx.Rollback(ctx)
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,11 +3,9 @@ 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 {
@ -22,12 +20,7 @@ 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 func() { defer tx.Rollback(ctx)
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,7 +7,6 @@ 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"
) )
@ -72,7 +71,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 errors.Wrap(err, "getting export") return err
} }
render.JSON(w, r, dataExportResponse{ render.JSON(w, r, dataExportResponse{

View file

@ -13,7 +13,6 @@ 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"
) )
@ -81,12 +80,7 @@ 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 func() { defer tx.Rollback(ctx)
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 {
@ -198,12 +192,7 @@ 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 func() { defer tx.Rollback(ctx)
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,7 +8,6 @@ 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"
@ -147,7 +146,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 errors.Wrap(err, "getting user") return err
} }
} }
@ -163,13 +162,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 errors.Wrap(err, "getting fields") return err
} }
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 errors.Wrap(err, "getting flags") return err
} }
var members []db.Member var members []db.Member
@ -177,7 +176,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 errors.Wrap(err, "getting user members") return err
} }
} }
@ -192,25 +191,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 errors.Wrap(err, "getting users") return err
} }
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 errors.Wrap(err, "getting fields") return err
} }
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 errors.Wrap(err, "getting members") return err
} }
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 errors.Wrap(err, "getting flags") return err
} }
render.JSON(w, r, GetMeResponse{ render.JSON(w, r, GetMeResponse{

View file

@ -12,7 +12,6 @@ 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"
) )
@ -196,13 +195,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 errors.Wrap(err, "converting avatar") return err
} }
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 errors.Wrap(err, "uploading avatar") return err
} }
avatarHash = &hash avatarHash = &hash
@ -220,14 +219,9 @@ 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 errors.Wrap(err, "creating transaction") return err
} }
defer func() { defer tx.Rollback(ctx)
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 {
@ -249,7 +243,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 errors.Wrap(err, "updating user") return err
} }
if req.Names != nil || req.Pronouns != nil { if req.Names != nil || req.Pronouns != nil {
@ -266,7 +260,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 errors.Wrap(err, "setting names/pronouns") return err
} }
u.Names = names u.Names = names
u.Pronouns = pronouns u.Pronouns = pronouns
@ -277,14 +271,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 errors.Wrap(err, "setting fields") return err
} }
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 errors.Wrap(err, "getting fields") return err
} }
} }
@ -297,7 +291,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 errors.Wrap(err, "updating flags") return err
} }
} }
@ -305,13 +299,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 errors.Wrap(err, "updating last active time") return err
} }
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 errors.Wrap(err, "committing transaction") return err
} }
// get fedi instance name if the user has a linked fedi account // get fedi instance name if the user has a linked fedi account
@ -327,7 +321,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 errors.Wrap(err, "getting flags") return err
} }
// echo the updated user back on success // echo the updated user back on success

View file

@ -5,7 +5,6 @@ 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"
) )
@ -14,7 +13,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 errors.Wrap(err, "getting user") return err
} }
render.JSON(w, r, u.Settings) render.JSON(w, r, u.Settings)

View file

@ -1,14 +1,10 @@
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"
) )
@ -16,11 +12,6 @@ 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
@ -33,20 +24,10 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han
return return
} }
rctx := chi.RouteContext(r.Context()) // otherwise, we log the error and return an internal server error message
hub.ConfigureScope(func(scope *sentry.Scope) { log.Errorf("error in http handler: %v", err)
scope.SetTag("method", rctx.RouteMethod)
scope.SetTag("path", rctx.RoutePattern())
})
var eventID *sentry.EventID = nil apiErr := APIError{Code: ErrInternalServerError}
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)
@ -55,17 +36,12 @@ 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"`
RatelimitReset *int `json:"ratelimit_reset,omitempty"` RatelimitReset *int `json:"ratelimit_reset,omitempty"`

View file

@ -1,89 +0,0 @@
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,9 +50,6 @@ 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://*"},
@ -100,23 +97,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

@ -45,10 +45,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,7 +5,6 @@
| 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 |
@ -24,7 +23,6 @@
| 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) |
@ -97,18 +95,18 @@ 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 |
| links | string[] | the member's new profile links. Maximum 25 links, and links must be between 1 and 256 characters | | links | string[] | the member's new profile links. Maximum 25 links, and links must be between 1 and 256 characters |
| 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, _not_ snowflake IDs. | | flags | string[] | the member's new flags. This must be an array of [pride flag](./#pride-flag) 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 |
### Delete member ### Delete member

View file

@ -5,7 +5,6 @@
| 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 |
@ -46,7 +45,6 @@
| 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 |
@ -91,7 +89,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, _not_ snowflake IDs. | | flags | string[] | the user's new flags. This must be an array of [pride flag](./#pride-flag) 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

@ -2,13 +2,12 @@
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 |
### Error codes ### Error codes

View file

@ -62,23 +62,19 @@ The "type" column in tables is formatted as follows:
## IDs ## IDs
### Snowflake IDs ::: info
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.
:::
For [multiple reasons](https://codeberg.org/pronounscc/pronouns.cc/issues/89), The API uses [xid](https://github.com/rs/xid) for unique IDs. These are always serialized as strings.
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.
**These can change at any time**, as short IDs can be rerolled once per hour. They can be rerolled once per hour.
## Images ## Images

View file

@ -1,4 +1,4 @@
# Base of frontend URLs (required) # Base of frontend URLs
PUBLIC_BASE_URL=http://localhost:5173 PUBLIC_BASE_URL=http://localhost:5173
# Base of media URLs, required for avatars, pride flags, and data exports # Base of media URLs, required for avatars, pride flags, and data exports

View file

@ -1,31 +1,20 @@
module.exports = { module.exports = {
root: true, root: true,
parser: "@typescript-eslint/parser", parser: '@typescript-eslint/parser',
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ["svelte3", "@typescript-eslint"], plugins: ['svelte3', '@typescript-eslint'],
ignorePatterns: ["*.cjs"], ignorePatterns: ['*.cjs'],
overrides: [{ files: ["*.svelte"], processor: "svelte3/svelte3" }], overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: { settings: {
"svelte3/typescript": () => require("typescript"), 'svelte3/typescript': () => require('typescript')
}, },
parserOptions: { parserOptions: {
sourceType: "module", sourceType: 'module',
ecmaVersion: 2020, ecmaVersion: 2020
}, },
env: { env: {
browser: true, browser: true,
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,22 +16,26 @@ 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 = map[string]struct{}{ var icons = [...]string{
`; `;
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 {
_, ok := icons[name] for i := range icons {
return ok if icons[i] == name {
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

@ -1,53 +1,52 @@
{ {
"name": "pronouns-fe", "name": "pronouns-fe",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .", "lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ." "format": "prettier --plugin-search-dir . --write ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-node": "^2.0.0", "@sveltejs/adapter-node": "^1.2.3",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^1.15.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@types/luxon": "^3.2.2",
"@sveltestrap/sveltestrap": "^6.0.5", "@types/markdown-it": "^12.2.3",
"@types/luxon": "^3.3.7", "@types/node": "^18.15.11",
"@types/markdown-it": "^13.0.7", "@types/sanitize-html": "^2.9.0",
"@types/node": "^18.15.11", "@typescript-eslint/eslint-plugin": "^5.57.1",
"@types/sanitize-html": "^2.9.0", "@typescript-eslint/parser": "^5.57.1",
"@typescript-eslint/eslint-plugin": "^5.57.1", "eslint": "^8.37.0",
"@typescript-eslint/parser": "^5.57.1", "eslint-config-prettier": "^8.8.0",
"eslint": "^8.37.0", "eslint-plugin-svelte3": "^4.0.0",
"eslint-config-prettier": "^8.8.0", "prettier": "^2.8.7",
"eslint-plugin-svelte3": "^4.0.0", "prettier-plugin-svelte": "^2.10.0",
"prettier": "^2.8.7", "svelte": "^3.58.0",
"prettier-plugin-svelte": "^2.10.1", "svelte-check": "^3.1.4",
"svelte": "^4.0.0", "svelte-hcaptcha": "^0.1.1",
"svelte-check": "^3.4.3", "sveltestrap": "^5.10.0",
"svelte-hcaptcha": "^0.1.1", "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", "dependencies": {
"dependencies": { "@fontsource/firago": "^4.5.3",
"@fontsource/firago": "^4.5.3", "@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", "pretty-bytes": "^6.1.0",
"pretty-bytes": "^6.1.0", "sanitize-html": "^2.10.0"
"sanitize-html": "^2.10.0" }
}
} }

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

@ -16,4 +16,23 @@ 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,9 +2,7 @@ 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);

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,3 @@
/* 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;
@ -70,7 +69,7 @@ export interface Settings {
} }
export interface Field { export interface Field {
name: string | null; name: string;
entries: FieldEntry[]; entries: FieldEntry[];
} }

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { NavLink } from "@sveltestrap/sveltestrap"; import { NavLink } from "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/sveltestrap"; import { Alert } from "sveltestrap";
export let error: APIError; export let error: APIError;
</script> </script>

View file

@ -4,7 +4,6 @@
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")) {
@ -32,7 +31,6 @@
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/sveltestrap"; import { Button, Icon, Tooltip } from "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/sveltestrap"; import { Icon, Tooltip } from "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,18 +46,13 @@
<div> <div>
<a href="/@{user.name}/{member.name}"> <a href="/@{user.name}/{member.name}">
<FallbackImage <FallbackImage urls={memberAvatars(member)} width={200} alt="Avatar for {member.name}" />
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}">
{member.display_name ?? member.name} {member.display_name ?? member.name}
{#if member.unlisted === true} {#if member.unlisted === true}
<span bind:this={iconElement} tabindex={0}><Icon name="lock" /></span> <span bind:this={iconElement} tabindex={0}><Icon name="lock"/></span>
<Tooltip target={iconElement} placement="top">This member is hidden</Tooltip> <Tooltip target={iconElement} placement="top">This member is hidden</Tooltip>
{/if} {/if}
</a> </a>

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Icon, Tooltip } from "@sveltestrap/sveltestrap"; import { Icon, Tooltip } from "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";
@ -15,11 +15,10 @@
$: currentPreference = $: currentPreference =
status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing; status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing;
let iconElement: HTMLSpanElement; let iconElement: HTMLElement;
</script> </script>
<span bind:this={iconElement} aria-hidden> <span bind:this={iconElement} tabindex={0}
<Icon name={currentPreference.icon} class={className} /> ><Icon name={currentPreference.icon} class={className} /></span
</span> >
<span class="visually-hidden">{currentPreference.tooltip}:</span> <Tooltip target={iconElement} placement="top">{currentPreference.tooltip}</Tooltip>
<Tooltip aria-hidden target={iconElement} placement="top">{currentPreference.tooltip}</Tooltip>

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Toast } from "@sveltestrap/sveltestrap"; import { Toast } from "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/sveltestrap"; import { Button, Input, InputGroup } from "sveltestrap";
import FieldEntry from "./FieldEntry.svelte"; import FieldEntry from "./FieldEntry.svelte";
export let field: Field; export let field: Field;
@ -36,7 +36,7 @@
</script> </script>
<div class="p-3"> <div class="p-3">
<h5>{field.name ? field.name : "New field"}</h5> <h5>{field.name}</h5>
<InputGroup class="m-1"> <InputGroup class="m-1">
<IconButton <IconButton
color="secondary" color="secondary"
@ -50,7 +50,7 @@
tooltip="Move field right" tooltip="Move field right"
click={() => moveField(false)} click={() => moveField(false)}
/> />
<Input placeholder="New field" bind:value={field.name} /> <Input bind:value={field.name} />
<Button color="danger" on:click={() => deleteField()}>Delete field</Button> <Button color="danger" on:click={() => deleteField()}>Delete field</Button>
</InputGroup> </InputGroup>
<hr /> <hr />
@ -65,7 +65,7 @@
/> />
{/each} {/each}
<form class="m-1 input-group" on:submit={addEntry}> <form class="input-group m-1" on:submit={addEntry}>
<input type="text" class="form-control" placeholder="New entry" bind:value={newEntry} /> <input type="text" class="form-control" placeholder="New entry" bind:value={newEntry} />
<IconButton color="success" icon="plus" tooltip="Add entry" /> <IconButton color="success" icon="plus" tooltip="Add entry" />
</form> </form>

View file

@ -9,7 +9,7 @@
DropdownToggle, DropdownToggle,
Icon, Icon,
Tooltip, Tooltip,
} from "@sveltestrap/sveltestrap"; } from "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/sveltestrap"; } from "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/sveltestrap"; } from "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/sveltestrap"; import { Button, Tooltip } from "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/sveltestrap"; import { Icon, Modal } from "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/sveltestrap"; import { Alert, Icon } from "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,8 +31,6 @@
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/sveltestrap"; import { Button } from "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) {
error(404, e as App.Error); throw error(404, e as APIError);
} }
throw e; throw e;

View file

@ -4,6 +4,7 @@
import { import {
Alert, Alert,
Badge,
Button, Button,
ButtonGroup, ButtonGroup,
Icon, Icon,
@ -13,8 +14,8 @@
ModalBody, ModalBody,
ModalFooter, ModalFooter,
Tooltip, Tooltip,
} from "@sveltestrap/sveltestrap"; } from "sveltestrap";
import { DateTime, FixedOffsetZone } from "luxon"; import { DateTime, Duration, FixedOffsetZone, Zone } 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";
@ -45,7 +46,6 @@
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,15 +190,14 @@
{/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" /> <Icon id="user-clock" name="clock" aria-label="This user's current time" /> {currentTime} <span class="text-body-secondary">(UTC{timezone})</span>
{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 Your profile is empty! You can customize it by going to the <a href="/@{data.name}/edit"
href="/@{data.name}/edit">edit profile</a >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>
@ -259,12 +258,6 @@
</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

@ -1,65 +0,0 @@
<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/sveltestrap"; import { Tooltip } from "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/sveltestrap"; import { Icon } from "sveltestrap";
export let link: string; export let link: string;
@ -21,7 +21,7 @@
</script> </script>
{#if isLink} {#if isLink}
<a href={link} class="text-decoration-none" rel="me nofollow noreferrer" target="_blank"> <a href={link} class="text-decoration-none">
<li class="py-2 py-lg-0"> <li class="py-2 py-lg-0">
<Icon name="globe" aria-hidden class="text-body" /> <Icon name="globe" aria-hidden class="text-body" />
<span class="text-decoration-underline">{displayLink}</span> <span class="text-decoration-underline">{displayLink}</span>

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/sveltestrap"; import { Button, FormGroup, Icon, Modal, ModalBody, ModalFooter } from "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
) { ) {
error(404, e as App.Error); throw error(404, e as APIError);
} }
error(500, e as App.Error); throw error(500, e as APIError);
} }
}; };

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/sveltestrap"; import { Alert, Button, Icon, InputGroup } from "sveltestrap";
import { import {
memberAvatars, memberAvatars,
pronounDisplay, pronounDisplay,
@ -22,7 +22,6 @@
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;
@ -155,12 +154,6 @@
</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,15 +5,7 @@
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 { import { Button, ButtonGroup, Modal, ModalBody, ModalFooter, Nav, NavItem } from "sveltestrap";
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
) { ) {
redirect(303, `/@${user.name}/${member.name}`); throw redirect(303, `/@${user.name}/${member.name}`);
} }
return { return {
@ -41,9 +41,8 @@ export const load = (async ({ params }) => {
pronouns: pronouns.autocomplete, pronouns: pronouns.autocomplete,
flags, flags,
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e) {
} catch (e: any) { if ("code" in e) throw error(500, e as APIError);
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/sveltestrap"; import { FormGroup, Icon, Input } from "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/sveltestrap"; import { Card, CardBody, CardHeader } from "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/sveltestrap"; import { Button, Icon } from "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,7 +45,9 @@
</div> </div>
</div> </div>
<div> <div>
<Button on:click={() => ($member.fields = [...$member.fields, { name: null, entries: [] }])}> <Button
on:click={() => ($member.fields = [...$member.fields, { name: "New field", 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/sveltestrap"; import { Alert, ButtonGroup, Input } from "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/sveltestrap"; import { Button, ButtonGroup, Icon } from "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/sveltestrap"; import { Button, Icon, Popover } from "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/sveltestrap"; import { Tooltip } from "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/sveltestrap"; import { Button, ButtonGroup, Icon, Nav, NavItem } from "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, MeUser, PronounsJson } from "$lib/api/entities"; import type { PrideFlag, APIError, MeUser, PronounsJson } from "$lib/api/entities";
import { apiFetchClient } from "$lib/api/fetch"; import { apiFetchClient } from "$lib/api/fetch";
import { error, redirect } from "@sveltejs/kit"; import { error, redirect, type 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) {
redirect(303, `/@${user.name}/edit`); throw redirect(303, `/@${user.name}/edit`);
} }
return { return {
@ -21,9 +21,8 @@ export const load = async ({ params }) => {
pronouns: pronouns.autocomplete, pronouns: pronouns.autocomplete,
flags, flags,
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e) {
} catch (e: any) { if ("code" in e) throw error(500, e as APIError);
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/sveltestrap"; import { FormGroup, Icon, Input } from "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/sveltestrap"; import { Card, CardBody, CardHeader } from "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/sveltestrap"; import { Button, Icon } from "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";
@ -45,7 +45,7 @@
</div> </div>
</div> </div>
<div> <div>
<Button on:click={() => ($user.fields = [...$user.fields, { name: null, entries: [] }])}> <Button on:click={() => ($user.fields = [...$user.fields, { name: "New field", 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/sveltestrap"; import { Alert, ButtonGroup, Input } from "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,14 +4,7 @@
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 { import { Button, ButtonGroup, FormGroup, Icon, Input, InputGroup } from "sveltestrap";
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/sveltestrap"; } from "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/sveltestrap"; } from "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/sveltestrap"; import { Button, Icon, Popover } from "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",
}); });
redirect(303, `/@${resp.name}`); throw redirect(303, `/@${resp.name}`);
} catch (e) { } catch (e) {
if ((e as APIError).code === ErrorCode.UserNotFound) { if ((e as APIError).code === ErrorCode.UserNotFound) {
error(404, e as App.Error); throw error(404, e as APIError);
} }
throw e; throw e;

View file

@ -15,7 +15,7 @@
Modal, Modal,
ModalBody, ModalBody,
ModalFooter, ModalFooter,
} from "@sveltestrap/sveltestrap"; } from "sveltestrap";
import type { PageData } from "./$types"; import type { PageData } from "./$types";
export let data: PageData; export let data: PageData;
@ -34,9 +34,7 @@
} }
}); });
const fediLogin = async (e: Event) => { const fediLogin = async () => {
e.preventDefault();
fediDisabled = true; fediDisabled = true;
try { try {
const resp = await apiFetch<{ url: string }>( const resp = await apiFetch<{ url: string }>(
@ -73,28 +71,26 @@
{/if} {/if}
</ListGroup> </ListGroup>
<Modal header="Pick an instance" isOpen={modalOpen} toggle={toggleModal}> <Modal header="Pick an instance" isOpen={modalOpen} toggle={toggleModal}>
<form on:submit={(e) => fediLogin(e)}> <ModalBody>
<ModalBody> <Input placeholder="Instance (e.g. mastodon.social)" bind:value={instance} />
<Input placeholder="Instance (e.g. mastodon.social)" bind:value={instance} /> <p class="text-muted mt-2">
<p class="text-muted mt-2"> <Icon name="info-circle-fill" aria-label="Info" /> This should be the domain you use to access
<Icon name="info-circle-fill" aria-label="Info" /> This should be the domain you use to posts. For example, if your username is <code>@timmie@mastodon.example</code>, but your
access posts. For example, if your username is <code>@timmie@mastodon.example</code>, user <em>page</em> is at
but your user <em>page</em> is at <code>social.mastodon.example/timmie</code>, you should fill in
<code>social.mastodon.example/timmie</code>, you should fill in <code>social.mastodon.example</code>.
<code>social.mastodon.example</code>. </p>
</p> {#if error}
{#if error} <div class="mt-2">
<div class="mt-2"> <ErrorAlert {error} />
<ErrorAlert {error} /> </div>
</div> {/if}
{/if} </ModalBody>
</ModalBody> <ModalFooter>
<ModalFooter> <Button color="primary" disabled={fediDisabled || instance === ""} on:click={fediLogin}
<Button type="submit" color="primary" disabled={fediDisabled || instance === ""} >Log in</Button
>Log in</Button >
> </ModalFooter>
</ModalFooter>
</form>
</Modal> </Modal>
<p class="text-muted mt-2 mx-1"> <p class="text-muted mt-2 mx-1">
<Icon name="info-circle-fill" aria-hidden /> By signing in, you consent to pronouns.cc storing <Icon name="info-circle-fill" aria-hidden /> By signing in, you consent to pronouns.cc storing

View file

@ -19,7 +19,7 @@
Modal, Modal,
ModalBody, ModalBody,
ModalFooter, ModalFooter,
} from "@sveltestrap/sveltestrap"; } from "sveltestrap";
export let authType: string; export let authType: string;
export let remoteName: string | undefined; export let remoteName: string | undefined;
@ -64,10 +64,8 @@
) => 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;
}; };
@ -90,8 +88,6 @@
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!,
}, },
}); });
@ -109,8 +105,6 @@
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: CallbackPage; let callbackPage: any;
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: CallbackPage; let callbackPage: any;
const signupForm = async (username: string, invite: string, captchaToken: string) => { const signupForm = async (username: string, invite: string, captchaToken: string) => {
try { try {

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