forked from mirrors/pronouns.cc
Compare commits
No commits in common. "main" and "feature/docs" have entirely different histories.
main
...
feature/do
162 changed files with 5062 additions and 9760 deletions
43
.air.toml
43
.air.toml
|
@ -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
2
.gitignore
vendored
|
@ -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
|
|
||||||
|
|
|
@ -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
|
|
|
@ -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
|
|
2
Makefile
2
Makefile
|
@ -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:
|
||||||
|
|
14
README.md
14
README.md
|
@ -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>
|
||||||
|
|
|
@ -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))
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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) }
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()")).
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
@ -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
480
backend/openapi.html
Normal file
File diff suppressed because one or more lines are too long
|
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
183
backend/routes/v1/bot/bot.go
Normal file
183
backend/routes/v1/bot/bot.go
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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{
|
||||||
|
|
|
@ -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"}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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{
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -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"`
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
@ -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 |
|
||||||
|
|
|
@ -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 |
|
||||||
|
|
||||||
|
|
|
@ -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 |
|
||||||
|
|
|
@ -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 |
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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: "^_",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
19
frontend/src/app.d.ts
vendored
|
@ -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 {};
|
||||||
|
|
|
@ -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
|
@ -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;
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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
Loading…
Reference in a new issue