Compare commits

..

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

162 changed files with 5062 additions and 9760 deletions

View file

@ -1,43 +0,0 @@
root = "."
tmp_dir = "tmp"
[build]
args_bin = ["web"]
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ."
delay = 1000
exclude_dir = ["docs", "frontend", "prns", "pronounslib", "tmp", "target", "node_modules"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

2
.gitignore vendored
View file

@ -12,5 +12,3 @@ package
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
target target
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

@ -1,65 +0,0 @@
package common
import (
"math/rand"
"sync/atomic"
"time"
)
// Generator is a snowflake generator.
// For compatibility with other snowflake implementations, both worker and PID are set,
// but they are randomized for every generator.
type IDGenerator struct {
inc *uint64
worker, pid uint64
}
var defaultGenerator = NewIDGenerator(0, 0)
// NewIDGenerator creates a new ID generator with the given worker and pid.
// If worker or pid is empty, it will be set to a random number.
func NewIDGenerator(worker, pid uint64) *IDGenerator {
if worker == 0 {
worker = rand.Uint64()
}
if pid == 0 {
pid = rand.Uint64()
}
g := &IDGenerator{
inc: new(uint64),
worker: worker % 32,
pid: pid % 32,
}
return g
}
// GenerateID generates a new snowflake with the default generator.
// If you need to customize the worker and PID, manually call (*Generator).Generate.
func GenerateID() Snowflake {
return defaultGenerator.Generate()
}
// GenerateID generates a new snowflake with the given time with the default generator.
// If you need to customize the worker and PID, manually call (*Generator).GenerateWithTime.
func GenerateIDWithTime(t time.Time) Snowflake {
return defaultGenerator.GenerateWithTime(t)
}
// Generate generates a snowflake with the current time.
func (g *IDGenerator) Generate() Snowflake {
return g.GenerateWithTime(time.Now())
}
// GenerateWithTime generates a snowflake with the given time.
// To generate a snowflake for comparison, use the top-level New function instead.
func (g *IDGenerator) GenerateWithTime(t time.Time) Snowflake {
increment := atomic.AddUint64(g.inc, 1)
ts := uint64(t.UnixMilli() - Epoch)
worker := g.worker << 17
pid := g.pid << 12
return Snowflake(ts<<22 | worker | pid | (increment % 4096))
}

View file

@ -1,83 +0,0 @@
package common
import (
"strconv"
"strings"
"time"
)
// Epoch is the pronouns.cc epoch (January 1st 2022 at 00:00:00 UTC) in milliseconds.
const Epoch = 1_640_995_200_000
const epochDuration = Epoch * time.Millisecond
const NullSnowflake = ^Snowflake(0)
// Snowflake is a 64-bit integer used as a unique ID, with an embedded timestamp.
type Snowflake uint64
// ID is an alias to Snowflake.
type ID = Snowflake
// ParseSnowflake parses a snowflake from a string.
func ParseSnowflake(sf string) (Snowflake, error) {
if sf == "null" {
return NullSnowflake, nil
}
i, err := strconv.ParseUint(sf, 10, 64)
if err != nil {
return 0, err
}
return Snowflake(i), nil
}
// NewSnowflake creates a new snowflake from the given time.
func NewSnowflake(t time.Time) Snowflake {
ts := time.Duration(t.UnixNano()) - epochDuration
return Snowflake((ts / time.Millisecond) << 22)
}
// String returns the snowflake as a string.
func (s Snowflake) String() string { return strconv.FormatUint(uint64(s), 10) }
// Time returns the creation time of the snowflake.
func (s Snowflake) Time() time.Time {
ts := time.Duration(s>>22)*time.Millisecond + epochDuration
return time.Unix(0, int64(ts))
}
func (s Snowflake) IsValid() bool {
return s != 0 && s != NullSnowflake
}
func (s Snowflake) MarshalJSON() ([]byte, error) {
if !s.IsValid() {
return []byte("null"), nil
}
return []byte(`"` + strconv.FormatUint(uint64(s), 10) + `"`), nil
}
func (s *Snowflake) UnmarshalJSON(src []byte) error {
sf, err := ParseSnowflake(strings.Trim(string(src), `"`))
if err != nil {
return err
}
*s = sf
return nil
}
func (s Snowflake) Worker() uint8 {
return uint8(s & 0x3E0000 >> 17)
}
func (s Snowflake) PID() uint8 {
return uint8(s & 0x1F000 >> 12)
}
func (s Snowflake) Increment() uint16 {
return uint16(s & 0xFFF)
}

View file

@ -1,39 +0,0 @@
package common
import "time"
type UserID Snowflake
func (id UserID) String() string { return Snowflake(id).String() }
func (id UserID) Time() time.Time { return Snowflake(id).Time() }
func (id UserID) IsValid() bool { return Snowflake(id).IsValid() }
func (id UserID) Worker() uint8 { return Snowflake(id).Worker() }
func (id UserID) PID() uint8 { return Snowflake(id).PID() }
func (id UserID) Increment() uint16 { return Snowflake(id).Increment() }
func (id UserID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
func (id *UserID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }
type MemberID Snowflake
func (id MemberID) String() string { return Snowflake(id).String() }
func (id MemberID) Time() time.Time { return Snowflake(id).Time() }
func (id MemberID) IsValid() bool { return Snowflake(id).IsValid() }
func (id MemberID) Worker() uint8 { return Snowflake(id).Worker() }
func (id MemberID) PID() uint8 { return Snowflake(id).PID() }
func (id MemberID) Increment() uint16 { return Snowflake(id).Increment() }
func (id MemberID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
func (id *MemberID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }
type FlagID Snowflake
func (id FlagID) String() string { return Snowflake(id).String() }
func (id FlagID) Time() time.Time { return Snowflake(id).Time() }
func (id FlagID) IsValid() bool { return Snowflake(id).IsValid() }
func (id FlagID) Worker() uint8 { return Snowflake(id).Worker() }
func (id FlagID) PID() uint8 { return Snowflake(id).PID() }
func (id FlagID) Increment() uint16 { return Snowflake(id).Increment() }
func (id FlagID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
func (id *FlagID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }

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

@ -9,7 +9,6 @@ import (
"io" "io"
"strings" "strings"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/davidbyttow/govips/v2/vips" "github.com/davidbyttow/govips/v2/vips"
@ -22,7 +21,6 @@ import (
type PrideFlag struct { type PrideFlag struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
SnowflakeID common.FlagID `json:"id_new"`
UserID xid.ID `json:"-"` UserID xid.ID `json:"-"`
Hash string `json:"hash"` Hash string `json:"hash"`
Name string `json:"name"` Name string `json:"name"`
@ -197,7 +195,6 @@ func (db *DB) CreateFlag(ctx context.Context, tx pgx.Tx, userID xid.ID, name, de
sql, args, err := sq.Insert("pride_flags"). sql, args, err := sq.Insert("pride_flags").
SetMap(map[string]any{ SetMap(map[string]any{
"id": xid.New(), "id": xid.New(),
"snowflake_id": common.GenerateID(),
"hash": "", "hash": "",
"user_id": userID.String(), "user_id": userID.String(),
"name": name, "name": name,

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

@ -6,8 +6,6 @@ import (
"strings" "strings"
"time" "time"
"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"
@ -24,7 +22,6 @@ const (
type Member struct { type Member struct {
ID xid.ID ID xid.ID
UserID xid.ID UserID xid.ID
SnowflakeID common.MemberID
SID string `db:"sid"` SID string `db:"sid"`
Name string Name string
DisplayName *string DisplayName *string
@ -42,14 +39,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",
} }
@ -76,23 +71,9 @@ func (db *DB) Member(ctx context.Context, id xid.ID) (m Member, err error) {
return m, nil return m, nil
} }
func (db *DB) MemberBySnowflake(ctx context.Context, id common.MemberID) (m Member, err error) {
sql, args, err := sq.Select("*").From("members").Where("snowflake_id = ?", id).ToSql()
if err != nil {
return m, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, db, &m, sql, args...)
if err != nil {
return m, errors.Wrap(err, "executing query")
}
return m, nil
}
// UserMember returns a member scoped by user. // UserMember returns a member scoped by user.
func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (m Member, err error) { func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (m Member, err error) {
sf, _ := common.ParseSnowflake(memberRef) // error can be ignored as the zero value will never be used as an ID sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).Where("(id = ? or name = ?)", memberRef, memberRef).ToSql()
sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).Where("(id = ? or snowflake_id = ? or name = ?)", memberRef, sf, memberRef).ToSql()
if err != nil { if err != nil {
return m, errors.Wrap(err, "building sql") return m, errors.Wrap(err, "building sql")
} }
@ -154,8 +135,8 @@ func (db *DB) CreateMember(
name string, displayName *string, bio string, links []string, name string, displayName *string, bio string, links []string,
) (m Member, err error) { ) (m Member, err error) {
sql, args, err := sq.Insert("members"). sql, args, err := sq.Insert("members").
Columns("user_id", "snowflake_id", "id", "sid", "name", "display_name", "bio", "links"). Columns("user_id", "id", "sid", "name", "display_name", "bio", "links").
Values(userID, common.GenerateID(), xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links). Values(userID, xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links).
Suffix("RETURNING *").ToSql() Suffix("RETURNING *").ToSql()
if err != nil { if err != nil {
return m, errors.Wrap(err, "building sql") return m, errors.Wrap(err, "building sql")
@ -288,12 +269,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

@ -1,66 +0,0 @@
package db
import (
"context"
"time"
"emperror.dev/errors"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
)
type Notice struct {
ID int
Notice string
StartTime time.Time
EndTime time.Time
}
func (db *DB) Notices(ctx context.Context) (ns []Notice, err error) {
sql, args, err := sq.Select("*").From("notices").OrderBy("id DESC").ToSql()
if err != nil {
return nil, errors.Wrap(err, "building sql")
}
err = pgxscan.Select(ctx, db, &ns, sql, args...)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return NotNull(ns), nil
}
func (db *DB) CreateNotice(ctx context.Context, notice string, start, end time.Time) (n Notice, err error) {
sql, args, err := sq.Insert("notices").SetMap(map[string]any{
"notice": notice,
"start_time": start,
"end_time": end,
}).Suffix("RETURNING *").ToSql()
if err != nil {
return n, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, db, &n, sql, args...)
if err != nil {
return n, errors.Wrap(err, "executing query")
}
return n, nil
}
const ErrNoNotice = errors.Sentinel("no current notice")
func (db *DB) CurrentNotice(ctx context.Context) (n Notice, err error) {
sql, args, err := sq.Select("*").From("notices").Where("end_time > ?", time.Now()).OrderBy("id DESC").Limit(1).ToSql()
if err != nil {
return n, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, db, &n, sql, args...)
if err != nil {
if errors.Cause(err) == pgx.ErrNoRows {
return n, ErrNoNotice
}
return n, errors.Wrap(err, "executing query")
}
return n, nil
}

View file

@ -22,7 +22,6 @@ import (
type User struct { type User struct {
ID xid.ID ID xid.ID
SnowflakeID common.UserID
SID string `db:"sid"` SID string `db:"sid"`
Username string Username string
DisplayName *string DisplayName *string
@ -55,7 +54,6 @@ type User struct {
ListPrivate bool ListPrivate bool
LastSIDReroll time.Time `db:"last_sid_reroll"` LastSIDReroll time.Time `db:"last_sid_reroll"`
Timezone *string Timezone *string
Settings UserSettings
DeletedAt *time.Time DeletedAt *time.Time
SelfDelete *bool SelfDelete *bool
@ -207,7 +205,7 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use
return u, err return u, err
} }
sql, args, err := sq.Insert("users").Columns("id", "snowflake_id", "username", "sid").Values(xid.New(), common.GenerateID(), username, squirrel.Expr("find_free_user_sid()")).Suffix("RETURNING *").ToSql() sql, args, err := sq.Insert("users").Columns("id", "username", "sid").Values(xid.New(), username, squirrel.Expr("find_free_user_sid()")).Suffix("RETURNING *").ToSql()
if err != nil { if err != nil {
return u, errors.Wrap(err, "building sql") return u, errors.Wrap(err, "building sql")
} }
@ -495,26 +493,6 @@ func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) {
return u, nil return u, nil
} }
// UserBySnowflake gets a user by their snowflake ID.
func (db *DB) UserBySnowflake(ctx context.Context, id common.UserID) (u User, err error) {
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
From("users").Where("snowflake_id = ?", id).ToSql()
if err != nil {
return u, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, db, &u, sql, args...)
if err != nil {
if errors.Cause(err) == pgx.ErrNoRows {
return u, ErrUserNotFound
}
return u, errors.Wrap(err, "getting user from db")
}
return u, nil
}
// Username gets a user by username. // Username gets a user by username.
func (db *DB) Username(ctx context.Context, name string) (u User, err error) { func (db *DB) Username(ctx context.Context, name string) (u User, err error) {
sql, args, err := sq.Select("*").From("users").Where("username = ?", name).ToSql() sql, args, err := sq.Select("*").From("users").Where("username = ?", name).ToSql()
@ -825,24 +803,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
}

View file

@ -1,27 +0,0 @@
package db
import (
"context"
"emperror.dev/errors"
"github.com/rs/xid"
)
type UserSettings struct {
ReadChangelog string `json:"read_changelog"`
ReadSettingsNotice string `json:"read_settings_notice"`
ReadGlobalNotice int `json:"read_global_notice"`
}
func (db *DB) UpdateUserSettings(ctx context.Context, id xid.ID, us UserSettings) error {
sql, args, err := sq.Update("users").Set("settings", us).Where("id = ?", id).ToSql()
if err != nil {
return errors.Wrap(err, "building sql")
}
_, err = db.Exec(ctx, sql, args...)
if err != nil {
return errors.Wrap(err, "executing query")
}
return 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)
@ -76,8 +62,9 @@ func run(c *cli.Context) error {
return nil return nil
case err := <-e: case err := <-e:
log.Fatalf("Error running server: %v", err) log.Fatalf("Error running server: %v", err)
return err
} }
return nil
} }
const MaxContentLength = 2 * 1024 * 1024 const MaxContentLength = 2 * 1024 * 1024

480
backend/openapi.html Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,30 +1,39 @@
package backend package backend
import ( import (
"net/http"
"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"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/user" "codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/user"
user2 "codeberg.org/pronounscc/pronouns.cc/backend/routes/v2/user"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render"
_ "embed" _ "embed"
) )
//go:embed openapi.html
var openapi string
// mountRoutes mounts all API routes on the server's router. // mountRoutes mounts all API routes on the server's router.
// they are all mounted under /v1/ // they are all mounted under /v1/
func mountRoutes(s *server.Server) { func mountRoutes(s *server.Server) {
// future-proofing for API versions
s.Router.Route("/v1", func(r chi.Router) { s.Router.Route("/v1", func(r chi.Router) {
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)
}) })
s.Router.Route("/v2", func(r chi.Router) { // API docs
user2.Mount(s, r) s.Router.Get("/", func(w http.ResponseWriter, r *http.Request) {
render.HTML(w, r, openapi)
}) })
} }

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

@ -4,7 +4,6 @@ import (
"net/http" "net/http"
"os" "os"
"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/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
@ -26,7 +25,6 @@ type Server struct {
type userResponse struct { type userResponse struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
Username string `json:"name"` Username string `json:"name"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Bio *string `json:"bio"` Bio *string `json:"bio"`
@ -53,7 +51,6 @@ type userResponse struct {
func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse { func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
return &userResponse{ return &userResponse{
ID: u.ID, ID: u.ID,
SnowflakeID: u.SnowflakeID,
Username: u.Username, Username: u.Username,
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Bio: u.Bio, Bio: u.Bio,
@ -185,7 +182,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

@ -8,13 +8,12 @@ import (
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/rs/xid" "github.com/rs/xid"
"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/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
) )
func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) (err error) { func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context() ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx) claims, _ := server.ClaimsFromContext(ctx)
@ -23,9 +22,12 @@ func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) (err error
return server.APIError{Code: server.ErrMissingPermissions, Details: "this token is read-only"} return server.APIError{Code: server.ErrMissingPermissions, Details: "this token is read-only"}
} }
var m db.Member id, err := xid.FromString(chi.URLParam(r, "memberRef"))
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil { if err != nil {
m, err = s.DB.Member(ctx, id) return server.APIError{Code: server.ErrMemberNotFound}
}
m, err := s.DB.Member(ctx, id)
if err != nil { if err != nil {
if err == db.ErrMemberNotFound { if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound} return server.APIError{Code: server.ErrMemberNotFound}
@ -33,18 +35,6 @@ func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) (err error
return errors.Wrap(err, "getting member") return errors.Wrap(err, "getting member")
} }
} else if id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
return errors.Wrap(err, "getting member")
}
} else {
return server.APIError{Code: server.ErrMemberNotFound}
}
if m.UserID != claims.UserID { if m.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotOwnMember} return server.APIError{Code: server.ErrNotOwnMember}
@ -66,7 +56,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

@ -4,9 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"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/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/chi/v5" "github.com/go-chi/chi/v5"
@ -16,7 +14,6 @@ import (
type GetMemberResponse struct { type GetMemberResponse struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
SnowflakeID common.MemberID `json:"id_new"`
SID string `json:"sid"` SID string `json:"sid"`
Name string `json:"name"` Name string `json:"name"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
@ -37,7 +34,6 @@ type GetMemberResponse struct {
func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.MemberFlag, isOwnMember bool) GetMemberResponse { func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.MemberFlag, isOwnMember bool) GetMemberResponse {
r := GetMemberResponse{ r := GetMemberResponse{
ID: m.ID, ID: m.ID,
SnowflakeID: m.SnowflakeID,
SID: m.SID, SID: m.SID,
Name: m.Name, Name: m.Name,
DisplayName: m.DisplayName, DisplayName: m.DisplayName,
@ -52,7 +48,6 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.Memb
User: PartialUser{ User: PartialUser{
ID: u.ID, ID: u.ID,
SnowflakeID: u.SnowflakeID,
Username: u.Username, Username: u.Username,
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Avatar: u.Avatar, Avatar: u.Avatar,
@ -69,43 +64,32 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.Memb
type PartialUser struct { type PartialUser struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
Username string `json:"name"` Username string `json:"name"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Avatar *string `json:"avatar"` Avatar *string `json:"avatar"`
CustomPreferences db.CustomPreferences `json:"custom_preferences"` CustomPreferences db.CustomPreferences `json:"custom_preferences"`
} }
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) (err error) { func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context() ctx := r.Context()
var m db.Member id, err := xid.FromString(chi.URLParam(r, "memberRef"))
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.Member(ctx, id)
if err != nil {
log.Errorf("getting member by xid: %v", err)
}
}
// xid was not valid
if !m.SnowflakeID.IsValid() {
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
if err != nil { if err != nil {
return server.APIError{ return server.APIError{
Code: server.ErrMemberNotFound, Code: server.ErrMemberNotFound,
} }
} }
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id)) m, err := s.DB.Member(ctx, id)
if err != nil { if err != nil {
return server.APIError{ return server.APIError{
Code: server.ErrMemberNotFound, Code: server.ErrMemberNotFound,
} }
} }
}
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 +103,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 +143,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 +173,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))
@ -202,22 +186,12 @@ func (s *Server) getMeMember(w http.ResponseWriter, r *http.Request) error {
} }
func (s *Server) parseUser(ctx context.Context, userRef string) (u db.User, err error) { func (s *Server) parseUser(ctx context.Context, userRef string) (u db.User, err error) {
// check xid first if id, err := xid.FromString(userRef); err != nil {
if id, err := xid.FromString(userRef); err == nil {
u, err := s.DB.User(ctx, id) u, err := s.DB.User(ctx, id)
if err == nil { if err == nil {
return u, nil return u, nil
} }
} }
// if not an xid, check by snowflake
if id, err := common.ParseSnowflake(userRef); err == nil {
u, err := s.DB.UserBySnowflake(ctx, common.UserID(id))
if err == nil {
return u, nil
}
}
// else, use username
return s.DB.Username(ctx, userRef) return s.DB.Username(ctx, userRef)
} }

View file

@ -3,10 +3,8 @@ package member
import ( import (
"net/http" "net/http"
"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"
@ -14,7 +12,6 @@ import (
type memberListResponse struct { type memberListResponse struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
SnowflakeID common.MemberID `json:"id_new"`
SID string `json:"sid"` SID string `json:"sid"`
Name string `json:"name"` Name string `json:"name"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
@ -31,7 +28,6 @@ func membersToMemberList(ms []db.Member, isSelf bool) []memberListResponse {
for i := range ms { for i := range ms {
resps[i] = memberListResponse{ resps[i] = memberListResponse{
ID: ms[i].ID, ID: ms[i].ID,
SnowflakeID: ms[i].SnowflakeID,
SID: ms[i].SID, SID: ms[i].SID,
Name: ms[i].Name, Name: ms[i].Name,
DisplayName: ms[i].DisplayName, DisplayName: ms[i].DisplayName,
@ -75,7 +71,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 +84,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"
) )
@ -39,16 +38,17 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
} }
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
u, err := s.DB.User(ctx, claims.UserID) u, err := s.DB.User(ctx, claims.UserID)
if err != nil { if err != nil {
return errors.Wrap(err, "getting user") return errors.Wrap(err, "getting user")
} }
var m db.Member m, err := s.DB.Member(ctx, id)
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
log.Debugf("%v/%v is xid", chi.URLParam(r, "memberRef"), id)
m, err = s.DB.Member(ctx, id)
if err != nil { if err != nil {
if err == db.ErrMemberNotFound { if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound} return server.APIError{Code: server.ErrMemberNotFound}
@ -56,21 +56,6 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "getting member") return errors.Wrap(err, "getting member")
} }
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
return errors.Wrap(err, "getting member")
}
}
if m.UserID != claims.UserID { if m.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotOwnMember} return server.APIError{Code: server.ErrNotOwnMember}
@ -221,13 +206,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,16 +230,11 @@ 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, id, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash)
if err != nil { if err != nil {
switch errors.Cause(err) { switch errors.Cause(err) {
case db.ErrNothingToUpdate: case db.ErrNothingToUpdate:
@ -278,10 +258,10 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
pronouns = *req.Pronouns pronouns = *req.Pronouns
} }
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, names, pronouns) err = s.DB.SetMemberNamesPronouns(ctx, tx, 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", id, err)
return errors.Wrap(err, "setting names/pronouns") return err
} }
m.Names = names m.Names = names
m.Pronouns = pronouns m.Pronouns = pronouns
@ -289,17 +269,17 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
var fields []db.Field var fields []db.Field
if req.Fields != nil { if req.Fields != nil {
err = s.DB.SetMemberFields(ctx, tx, m.ID, *req.Fields) err = s.DB.SetMemberFields(ctx, tx, 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", 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, 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", id, err)
return errors.Wrap(err, "getting fields") return err
} }
} }
@ -312,7 +292,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 +300,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
@ -341,7 +321,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} }
func (s *Server) rerollMemberSID(w http.ResponseWriter, r *http.Request) (err error) { func (s *Server) rerollMemberSID(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context() ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx) claims, _ := server.ClaimsFromContext(ctx)
@ -350,39 +330,25 @@ func (s *Server) rerollMemberSID(w http.ResponseWriter, r *http.Request) (err er
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
} }
var m db.Member id, err := xid.FromString(chi.URLParam(r, "memberRef"))
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
if err != nil { if err != nil {
return server.APIError{Code: server.ErrMemberNotFound} return server.APIError{Code: server.ErrMemberNotFound}
} }
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
}
u, err := s.DB.User(ctx, claims.UserID) u, err := s.DB.User(ctx, claims.UserID)
if err != nil { if err != nil {
return errors.Wrap(err, "getting user") return errors.Wrap(err, "getting user")
} }
m, err := s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
return errors.Wrap(err, "getting member")
}
if m.UserID != claims.UserID { if m.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotOwnMember} return server.APIError{Code: server.ErrNotOwnMember}
} }

View file

@ -4,8 +4,6 @@ import (
"net/http" "net/http"
"os" "os"
"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"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
@ -27,12 +25,6 @@ type MetaResponse struct {
Users MetaUsers `json:"users"` Users MetaUsers `json:"users"`
Members int64 `json:"members"` Members int64 `json:"members"`
RequireInvite bool `json:"require_invite"` RequireInvite bool `json:"require_invite"`
Notice *MetaNotice `json:"notice"`
}
type MetaNotice struct {
ID int `json:"id"`
Notice string `json:"notice"`
} }
type MetaUsers struct { type MetaUsers struct {
@ -47,18 +39,6 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
numUsers, numMembers, activeDay, activeWeek, activeMonth := s.DB.Counts(ctx) numUsers, numMembers, activeDay, activeWeek, activeMonth := s.DB.Counts(ctx)
var notice *MetaNotice
if n, err := s.DB.CurrentNotice(ctx); err != nil {
if err != db.ErrNoNotice {
log.Errorf("getting notice: %v", err)
}
} else {
notice = &MetaNotice{
ID: n.ID,
Notice: n.Notice,
}
}
render.JSON(w, r, MetaResponse{ render.JSON(w, r, MetaResponse{
GitRepository: server.Repository, GitRepository: server.Repository,
GitCommit: server.Revision, GitCommit: server.Revision,
@ -70,7 +50,6 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
}, },
Members: numMembers, Members: numMembers,
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true", RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
Notice: notice,
}) })
return nil return nil
} }

View file

@ -3,7 +3,6 @@ package mod
import ( import (
"net/http" "net/http"
"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/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
@ -19,7 +18,7 @@ type CreateReportRequest struct {
Reason string `json:"reason"` Reason string `json:"reason"`
} }
func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) (err error) { func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context() ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx) claims, _ := server.ClaimsFromContext(ctx)
@ -27,33 +26,20 @@ func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) (err e
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
} }
var u db.User userID, err := xid.FromString(chi.URLParam(r, "id"))
if id, err := xid.FromString(chi.URLParam(r, "id")); err == nil { if err != nil {
u, err = s.DB.User(ctx, id) return server.APIError{Code: server.ErrBadRequest, Details: "Invalid user ID"}
}
u, err := s.DB.User(ctx, userID)
if err != nil { if err != nil {
if err == db.ErrUserNotFound { if err == db.ErrUserNotFound {
return server.APIError{Code: server.ErrUserNotFound} return server.APIError{Code: server.ErrUserNotFound}
} }
log.Errorf("getting user %v: %v", id, err) log.Errorf("getting user %v: %v", userID, err)
return errors.Wrap(err, "getting user") return errors.Wrap(err, "getting user")
} }
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "id"))
if err != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
u, err = s.DB.UserBySnowflake(ctx, common.UserID(id))
if err != nil {
if err == db.ErrUserNotFound {
return server.APIError{Code: server.ErrUserNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
}
if u.DeletedAt != nil { if u.DeletedAt != nil {
return server.APIError{Code: server.ErrUserNotFound} return server.APIError{Code: server.ErrUserNotFound}
@ -87,32 +73,19 @@ func (s *Server) createMemberReport(w http.ResponseWriter, r *http.Request) erro
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
} }
var m db.Member memberID, err := xid.FromString(chi.URLParam(r, "id"))
if id, err := xid.FromString(chi.URLParam(r, "id")); err == nil { if err != nil {
m, err = s.DB.Member(ctx, id) return server.APIError{Code: server.ErrBadRequest, Details: "Invalid member ID"}
}
m, err := s.DB.Member(ctx, memberID)
if err != nil { if err != nil {
if err == db.ErrMemberNotFound { if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound} return server.APIError{Code: server.ErrMemberNotFound}
} }
log.Errorf("getting user %v: %v", id, err) log.Errorf("getting member %v: %v", memberID, err)
return errors.Wrap(err, "getting user") return errors.Wrap(err, "getting member")
}
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "id"))
if err != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
} }
u, err := s.DB.User(ctx, m.UserID) u, err := s.DB.User(ctx, m.UserID)

View file

@ -1,55 +0,0 @@
package mod
import (
"net/http"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/aarondl/opt/omit"
"github.com/go-chi/render"
)
type createNoticeRequest struct {
Notice string `json:"notice"`
Start omit.Val[time.Time] `json:"start"`
End time.Time `json:"end"`
}
type noticeResponse struct {
ID int `json:"id"`
Notice string `json:"notice"`
StartTime time.Time `json:"start"`
EndTime time.Time `json:"end"`
}
func (s *Server) createNotice(w http.ResponseWriter, r *http.Request) error {
var req createNoticeRequest
err := render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if common.StringLength(&req.Notice) > 2000 {
return server.APIError{Code: server.ErrBadRequest, Details: "Notice is too long, max 2000 characters"}
}
start := req.Start.GetOr(time.Now())
if req.End.IsZero() {
return server.APIError{Code: server.ErrBadRequest, Details: "`end` is missing or invalid"}
}
n, err := s.DB.CreateNotice(r.Context(), req.Notice, start, req.End)
if err != nil {
return errors.Wrap(err, "creating notice")
}
render.JSON(w, r, noticeResponse{
ID: n.ID,
Notice: n.Notice,
StartTime: n.StartTime,
EndTime: n.EndTime,
})
return nil
}

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

@ -22,8 +22,6 @@ func Mount(srv *server.Server, r chi.Router) {
r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter)) r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter))
r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport)) r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport))
r.Post("/notices", server.WrapHandler(s.createNotice))
}) })
r.With(MustAdmin).Handle("/metrics", promhttp.Handler()) r.With(MustAdmin).Handle("/metrics", promhttp.Handler())

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

@ -1,7 +1,6 @@
package user package user
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@ -13,7 +12,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 +79,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 {
@ -128,24 +121,6 @@ type patchUserFlagRequest struct {
Description *string `json:"description"` Description *string `json:"description"`
} }
func (s *Server) parseFlag(ctx context.Context, flags []db.PrideFlag, flagRef string) (db.PrideFlag, bool) {
if id, err := xid.FromString(flagRef); err == nil {
for _, f := range flags {
if f.ID == id {
return f, true
}
}
}
if id, err := common.ParseSnowflake(flagRef); err == nil {
for _, f := range flags {
if f.SnowflakeID == common.FlagID(id) {
return f, true
}
}
}
return db.PrideFlag{}, false
}
func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error { func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context() ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx) claims, _ := server.ClaimsFromContext(ctx)
@ -154,13 +129,28 @@ func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
} }
flagID, err := xid.FromString(chi.URLParam(r, "flagID"))
if err != nil {
return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"}
}
flags, err := s.DB.AccountFlags(ctx, claims.UserID) flags, err := s.DB.AccountFlags(ctx, claims.UserID)
if err != nil { if err != nil {
return errors.Wrap(err, "getting current user flags") return errors.Wrap(err, "getting current user flags")
} }
if len(flags) >= db.MaxPrideFlags {
flag, ok := s.parseFlag(ctx, flags, chi.URLParam(r, "flagID")) return server.APIError{
if !ok { Code: server.ErrFlagLimitReached,
}
}
var found bool
for _, flag := range flags {
if flag.ID == flagID {
found = true
break
}
}
if !found {
return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"} return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"}
} }
@ -198,14 +188,9 @@ 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, flagID, req.Name, req.Description, nil)
if err != nil { if err != nil {
return errors.Wrap(err, "updating flag") return errors.Wrap(err, "updating flag")
} }
@ -227,16 +212,19 @@ func (s *Server) deleteUserFlag(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
} }
flags, err := s.DB.AccountFlags(ctx, claims.UserID) flagID, err := xid.FromString(chi.URLParam(r, "flagID"))
if err != nil { if err != nil {
return errors.Wrap(err, "getting current user flags") return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"}
} }
flag, ok := s.parseFlag(ctx, flags, chi.URLParam(r, "flagID")) flag, err := s.DB.UserFlag(ctx, flagID)
if !ok { if err != nil {
return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"} if err == db.ErrFlagNotFound {
return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"}
} }
return errors.Wrap(err, "getting flag object")
}
if flag.UserID != claims.UserID { if flag.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"} return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"}
} }

View file

@ -4,11 +4,9 @@ import (
"net/http" "net/http"
"time" "time"
"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/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"
@ -16,7 +14,6 @@ import (
type GetUserResponse struct { type GetUserResponse struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
SID string `json:"sid"` SID string `json:"sid"`
Username string `json:"name"` Username string `json:"name"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
@ -61,7 +58,6 @@ type GetMeResponse struct {
type PartialMember struct { type PartialMember struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
SnowflakeID common.MemberID `json:"id_new"`
SID string `json:"sid"` SID string `json:"sid"`
Name string `json:"name"` Name string `json:"name"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
@ -75,7 +71,6 @@ type PartialMember struct {
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags []db.UserFlag) GetUserResponse { func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags []db.UserFlag) GetUserResponse {
resp := GetUserResponse{ resp := GetUserResponse{
ID: u.ID, ID: u.ID,
SnowflakeID: u.SnowflakeID,
SID: u.SID, SID: u.SID,
Username: u.Username, Username: u.Username,
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
@ -102,7 +97,6 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags [
for i := range members { for i := range members {
resp.Members[i] = PartialMember{ resp.Members[i] = PartialMember{
ID: members[i].ID, ID: members[i].ID,
SnowflakeID: members[i].SnowflakeID,
SID: members[i].SID, SID: members[i].SID,
Name: members[i].Name, Name: members[i].Name,
DisplayName: members[i].DisplayName, DisplayName: members[i].DisplayName,
@ -130,15 +124,6 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
} }
} }
if u.ID.IsNil() {
if id, err := common.ParseSnowflake(userRef); err == nil {
u, err = s.DB.UserBySnowflake(ctx, common.UserID(id))
if err != nil {
log.Errorf("getting user by snowflake: %v", err)
}
}
}
if u.ID.IsNil() { if u.ID.IsNil() {
u, err = s.DB.Username(ctx, userRef) u, err = s.DB.Username(ctx, userRef)
if err == db.ErrUserNotFound { if err == db.ErrUserNotFound {
@ -147,7 +132,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 +148,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 +162,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 +177,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

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

View file

@ -1,49 +0,0 @@
package user
import (
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/aarondl/opt/omitnull"
"github.com/go-chi/render"
)
type PatchSettingsRequest struct {
ReadChangelog omitnull.Val[string] `json:"read_changelog"`
ReadSettingsNotice omitnull.Val[string] `json:"read_settings_notice"`
ReadGlobalNotice omitnull.Val[int] `json:"read_global_notice"`
}
func (s *Server) PatchSettings(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
var req PatchSettingsRequest
err = render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if !req.ReadChangelog.IsUnset() {
u.Settings.ReadChangelog = req.ReadChangelog.GetOrZero()
}
if !req.ReadSettingsNotice.IsUnset() {
u.Settings.ReadSettingsNotice = req.ReadSettingsNotice.GetOrZero()
}
if !req.ReadGlobalNotice.IsUnset() {
u.Settings.ReadGlobalNotice = req.ReadGlobalNotice.GetOrZero()
}
err = s.DB.UpdateUserSettings(ctx, u.ID, u.Settings)
if err != nil {
return errors.Wrap(err, "updating user settings")
}
render.JSON(w, r, u.Settings)
return nil
}

View file

@ -1,23 +0,0 @@
package user
import (
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
)
type Server struct {
*server.Server
}
func Mount(srv *server.Server, r chi.Router) {
s := &Server{
Server: srv,
}
r.Route("/users", func(r chi.Router) {
r.With(server.MustAuth).Group(func(r chi.Router) {
r.Get("/@me/settings", server.WrapHandler(s.GetSettings))
r.Patch("/@me/settings", server.WrapHandler(s.PatchSettings))
})
})
}

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,15 +36,10 @@ 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"`

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

@ -46,9 +46,8 @@ 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) |
@ -98,7 +96,7 @@ Returns the updated [member](./members#member-object) on success.
#### Request body parameters #### Request body parameters
| Field | Type | Description | | Field | Type | Description |
| ------------ | --------------- | ------------------------------------------------------------------------------------------------------ | | ------------------ | -------------------- | --------------------------------------------------------------------------------------------------- |
| name | string | the member's new name. Must be unique per user, and be between 1 and 100 characters. | | name | string | the member's new name. Must be unique per user, and be between 1 and 100 characters. |
| display_name | string | the member's new display name. Must be between 1 and 100 characters | | display_name | string | the member's new display name. Must be between 1 and 100 characters |
| bio | string | the member's new bio. Must be between 1 and 1000 characters | | bio | string | the member's new bio. Must be between 1 and 1000 characters |
@ -106,7 +104,7 @@ Returns the updated [member](./members#member-object) on success.
| names | field_entry[] | the member's new preferred names | | names | field_entry[] | the member's new preferred names |
| pronouns | pronoun_entry[] | the member's new preferred pronouns | | pronouns | pronoun_entry[] | the member's new preferred pronouns |
| fields | field[] | the member's new profile fields | | fields | field[] | the member's new profile fields |
| flags | string[] | the member's new flags. This must be an array of [pride flag](./#pride-flag) IDs, _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 |

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

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

View file

@ -62,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

@ -12,13 +12,11 @@
"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/markdown-it": "^13.0.7",
"@types/node": "^18.15.11", "@types/node": "^18.15.11",
"@types/sanitize-html": "^2.9.0", "@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.57.1", "@typescript-eslint/eslint-plugin": "^5.57.1",
@ -27,13 +25,14 @@
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-svelte3": "^4.0.0", "eslint-plugin-svelte3": "^4.0.0",
"prettier": "^2.8.7", "prettier": "^2.8.7",
"prettier-plugin-svelte": "^2.10.1", "prettier-plugin-svelte": "^2.10.0",
"svelte": "^4.0.0", "svelte": "^3.58.0",
"svelte-check": "^3.4.3", "svelte-check": "^3.1.4",
"svelte-hcaptcha": "^0.1.1", "svelte-hcaptcha": "^0.1.1",
"sveltestrap": "^5.10.0",
"tslib": "^2.5.0", "tslib": "^2.5.0",
"typescript": "^5.0.0", "typescript": "^4.9.5",
"vite": "^5.0.0", "vite": "^4.2.1",
"vite-plugin-markdown": "^2.1.0" "vite-plugin-markdown": "^2.1.0"
}, },
"type": "module", "type": "module",
@ -42,8 +41,8 @@
"@popperjs/core": "^2.11.7", "@popperjs/core": "^2.11.7",
"@sentry/node": "^7.46.0", "@sentry/node": "^7.46.0",
"base64-arraybuffer": "^1.0.2", "base64-arraybuffer": "^1.0.2",
"bootstrap": "^5.3.2", "bootstrap": "5.3.0-alpha1",
"bootstrap-icons": "^1.11.2", "bootstrap-icons": "^1.10.4",
"jose": "^4.13.1", "jose": "^4.13.1",
"luxon": "^3.3.0", "luxon": "^3.3.0",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",

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

@ -16,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;
@ -8,7 +7,6 @@ export const MAX_FLAGS = 500;
export interface User { export interface User {
id: string; id: string;
id_new: string;
sid: string; sid: string;
name: string; name: string;
display_name: string | null; display_name: string | null;
@ -63,14 +61,8 @@ export interface MeUser extends User {
timezone: string | null; timezone: string | null;
} }
export interface Settings {
read_changelog: string;
read_settings_notice: string;
read_global_notice: number;
}
export interface Field { export interface Field {
name: string | null; name: string;
entries: FieldEntry[]; entries: FieldEntry[];
} }
@ -87,7 +79,6 @@ export interface Pronoun {
export interface PartialMember { export interface PartialMember {
id: string; id: string;
id_new: string;
sid: string; sid: string;
name: string; name: string;
display_name: string | null; display_name: string | null;
@ -108,7 +99,6 @@ export interface Member extends PartialMember {
export interface MemberPartialUser { export interface MemberPartialUser {
id: string; id: string;
id_new: string;
name: string; name: string;
display_name: string | null; display_name: string | null;
avatar: string | null; avatar: string | null;
@ -117,7 +107,6 @@ export interface MemberPartialUser {
export interface PrideFlag { export interface PrideFlag {
id: string; id: string;
id_new: string;
hash: string; hash: string;
name: string; name: string;
description: string | null; description: string | null;

View file

@ -11,16 +11,9 @@ export async function apiFetch<T>(
body, body,
token, token,
headers, headers,
version, }: { method?: string; body?: any; token?: string; headers?: Record<string, string> },
}: {
method?: string;
body?: any;
token?: string;
headers?: Record<string, string>;
version?: number;
},
) { ) {
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v${version || 1}${path}`, { const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, {
method: method || "GET", method: method || "GET",
headers: { headers: {
...(token ? { Authorization: token } : {}), ...(token ? { Authorization: token } : {}),
@ -35,18 +28,12 @@ export async function apiFetch<T>(
return data as T; return data as T;
} }
export const apiFetchClient = async <T>( export const apiFetchClient = async <T>(path: string, method = "GET", body: any = null) => {
path: string,
method = "GET",
body: any = null,
version = 1,
) => {
try { try {
const data = await apiFetch<T>(path, { const data = await apiFetch<T>(path, {
method, method,
body, body,
token: localStorage.getItem("pronouns-token") || undefined, token: localStorage.getItem("pronouns-token") || undefined,
version,
}); });
return data; return data;
} catch (e) { } catch (e) {
@ -68,16 +55,9 @@ export async function fastFetch(
body, body,
token, token,
headers, headers,
version, }: { method?: string; body?: any; token?: string; headers?: Record<string, string> },
}: {
method?: string;
body?: any;
token?: string;
headers?: Record<string, string>;
version?: number;
},
) { ) {
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v${version || 1}${path}`, { const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, {
method: method || "GET", method: method || "GET",
headers: { headers: {
...(token ? { Authorization: token } : {}), ...(token ? { Authorization: token } : {}),
@ -91,18 +71,12 @@ export async function fastFetch(
} }
/** Fetches the specified path without parsing the response body. */ /** Fetches the specified path without parsing the response body. */
export const fastFetchClient = async ( export const fastFetchClient = async (path: string, method = "GET", body: any = null) => {
path: string,
method = "GET",
body: any = null,
version = 1,
) => {
try { try {
await fastFetch(path, { await fastFetch(path, {
method, method,
body, body,
token: localStorage.getItem("pronouns-token") || undefined, token: localStorage.getItem("pronouns-token") || undefined,
version,
}); });
} catch (e) { } catch (e) {
if ((e as APIError).code === ErrorCode.InvalidToken) { if ((e as APIError).code === ErrorCode.InvalidToken) {

View file

@ -11,7 +11,6 @@ export interface MetaResponse {
users: MetaUsers; users: MetaUsers;
members: number; members: number;
require_invite: boolean; require_invite: boolean;
notice: { id: number; notice: string } | null;
} }
export interface MetaUsers { export interface MetaUsers {

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

@ -1,7 +1,7 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import type { MeUser, Settings } from "./api/entities"; import type { MeUser } from "./api/entities";
const initialUserValue = null; const initialUserValue = null;
export const userStore = writable<MeUser | null>(initialUserValue); export const userStore = writable<MeUser | null>(initialUserValue);
@ -13,10 +13,4 @@ const initialThemeValue = browser
export const themeStore = writable<string>(initialThemeValue); export const themeStore = writable<string>(initialThemeValue);
const defaultSettingsValue = {
settings: { read_changelog: "0.0.0", read_settings_notice: "0" } as Settings,
current: false,
};
export const settingsStore = writable(defaultSettingsValue);
export const CURRENT_CHANGELOG = "0.6.0"; export const CURRENT_CHANGELOG = "0.6.0";

View file

@ -7,18 +7,8 @@ const md = new MarkdownIt({
linkify: true, linkify: true,
}).disable(["heading", "lheading", "link", "table", "blockquote"]); }).disable(["heading", "lheading", "link", "table", "blockquote"]);
const unsafeMd = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
});
export function renderMarkdown(src: string | null) { export function renderMarkdown(src: string | null) {
return src ? sanitize(md.render(src)) : null; return src ? sanitize(md.render(src)) : null;
} }
export function renderUnsafeMarkdown(src: string) {
return sanitize(unsafeMd.render(src));
}
export const charCount = (str: string) => [...str].length; export const charCount = (str: string) => [...str].length;

View file

@ -22,8 +22,7 @@ export const load = (async () => {
}, },
members: 0, members: 0,
require_invite: false, require_invite: false,
notice: null, };
} as MetaResponse;
} else { } else {
throw e; throw e;
} }

View file

@ -10,13 +10,9 @@
import Navigation from "./nav/Navigation.svelte"; import Navigation from "./nav/Navigation.svelte";
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
import { version } from "$app/environment"; import { version } from "$app/environment";
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 { Icon } from "sveltestrap";
import { apiFetchClient } from "$lib/api/fetch";
import type { Settings } from "$lib/api/entities";
import { renderUnsafeMarkdown } from "$lib/utils";
export let data: LayoutData; export let data: LayoutData;
@ -25,22 +21,6 @@
if (versionParts.length >= 3) commit = versionParts[2].slice(1); if (versionParts.length >= 3) commit = versionParts[2].slice(1);
const versionMismatch = data.git_commit !== commit && data.git_commit !== "[unknown]"; const versionMismatch = data.git_commit !== commit && data.git_commit !== "[unknown]";
const readNotice = async () => {
try {
const resp = await apiFetchClient<Settings>(
"/users/@me/settings",
"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 },
2,
);
settingsStore.set({ current: true, settings: resp });
} catch (e) {
console.log("updating settings:", e);
}
};
</script> </script>
<svelte:head> <svelte:head>
@ -54,12 +34,6 @@
<div class="flex-grow-1"> <div class="flex-grow-1">
<Navigation commit={data.git_commit} /> <Navigation commit={data.git_commit} />
<div class="container"> <div class="container">
{#if data.notice && $settingsStore.current && data.notice.id > $settingsStore.settings.read_global_notice}
<Alert color="secondary" isOpen={true} toggle={() => readNotice()}>
{@html renderUnsafeMarkdown(data.notice.notice)}
</Alert>
{/if}
<slot /> <slot />
<div class="position-absolute top-0 start-50 translate-middle-x"> <div class="position-absolute top-0 start-50 translate-middle-x">
{#each $toastStore as toast} {#each $toastStore as toast}

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>
@ -281,7 +274,7 @@
/> />
{/if} {/if}
{#if $userStore && $userStore.id !== data.id} {#if $userStore && $userStore.id !== data.id}
<ReportButton subject="user" reportUrl="/users/{data.id_new}/reports" /> <ReportButton subject="user" reportUrl="/users/{data.id}/reports" />
{/if} {/if}
</InputGroup> </InputGroup>
</div> </div>

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>
@ -177,7 +170,7 @@
/> />
{/if} {/if}
{#if $userStore && $userStore.id !== data.user.id} {#if $userStore && $userStore.id !== data.user.id}
<ReportButton subject="member" reportUrl="/members/{data.id_new}/reports" /> <ReportButton subject="member" reportUrl="/members/{data.id}/reports" />
{/if} {/if}
</InputGroup> </InputGroup>
</div> </div>

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";
@ -42,7 +34,7 @@
const deleteMember = async () => { const deleteMember = async () => {
try { try {
await fastFetchClient(`/members/${data.member.id_new}`, "DELETE"); await fastFetchClient(`/members/${data.member.id}`, "DELETE");
toggleDeleteOpen(); toggleDeleteOpen();
addToast({ addToast({
@ -76,7 +68,7 @@
}); });
try { try {
const resp = await apiFetchClient<Member>(`/members/${data.member.id_new}`, "PATCH", { const resp = await apiFetchClient<Member>(`/members/${data.member.id}`, "PATCH", {
name: $member.name, name: $member.name,
display_name: $member.display_name, display_name: $member.display_name,
avatar: $member.avatar, avatar: $member.avatar,

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 { Alert, 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";
@ -27,10 +27,10 @@
</script> </script>
{#if $member.fields.length === 0} {#if $member.fields.length === 0}
<div class="my-2"> <Alert class="mt-3" color="secondary" fade={false}>
Fields are extra categories you can add separate from names and pronouns.<br /> Fields are extra categories you can add separate from names and pronouns.<br />
For example, you could use them for gender terms, honorifics, or compliments. For example, you could use them for gender terms, honorifics, or compliments.
</div> </Alert>
{/if} {/if}
<div class="grid gap-3"> <div class="grid gap-3">
<div class="row row-cols-1 row-cols-md-2"> <div class="row row-cols-1 row-cols-md-2">
@ -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";
@ -25,7 +25,7 @@
const rerollSid = async () => { const rerollSid = async () => {
try { try {
const resp = await apiFetchClient<Member>(`/members/${data.member.id_new}/reroll`); const resp = await apiFetchClient<Member>(`/members/${data.member.id}/reroll`);
addToast({ header: "Success", body: "Rerolled short ID!" }); addToast({ header: "Success", body: "Rerolled short ID!" });
error = null; error = null;
$member.sid = resp.sid; $member.sid = resp.sid;

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