forked from mirrors/pronouns.cc
Compare commits
95 commits
Author | SHA1 | Date | |
---|---|---|---|
8f34367d1a | |||
|
5fcd87a94a | ||
|
0633a32f64 | ||
|
623cdb545e | ||
|
4745a1c04b | ||
|
4e78d36eff | ||
|
31e1862ca9 | ||
|
4308bd4d98 | ||
|
40672d6d41 | ||
|
cfed74d6bf | ||
|
b29a0c86db | ||
|
1339550c80 | ||
|
55479ae8da | ||
|
ebc10d9558 | ||
|
ac603ac18e | ||
|
00abe1cb32 | ||
|
c13c4e90b6 | ||
|
e37b5be376 | ||
|
44b667ff43 | ||
|
e0ba5ea0dc | ||
|
d559d1a036 | ||
|
34002e77d9 | ||
|
97391c51d8 | ||
|
65b171696a | ||
|
cb8cfb9d2f | ||
|
a297ec681e | ||
|
0e6f3a47f4 | ||
|
fc1f4d03f1 | ||
|
9f266ee1a8 | ||
|
b04ed68832 | ||
|
a6d31d150c | ||
|
f424228fee | ||
|
bb64378c13 | ||
|
0022ae6112 | ||
|
364c008554 | ||
|
4f62d8d589 | ||
|
00d3f56f2e | ||
|
636ee7369d | ||
|
b6424cac9c | ||
|
dd9c9c442c | ||
|
467069c898 | ||
|
a1b2fce9af | ||
|
727848c801 | ||
|
2da388df2e | ||
|
153812d79f | ||
|
bad1df395a | ||
|
f39a762072 | ||
|
e03c9827b9 | ||
|
cb563bc00b | ||
|
c780470afe | ||
|
6c8f2b648e | ||
|
b6cc5bb130 | ||
|
41f5d46891 | ||
|
04db0507ba | ||
|
1b9a5deb78 | ||
|
0171f54592 | ||
|
b5a6d51491 | ||
|
4377d38933 | ||
|
58eff3ef4b | ||
|
c6195218c5 | ||
|
bc1948316c | ||
|
50b584c8ea | ||
|
4aa4d35362 | ||
|
4df9a4c368 | ||
|
0595e8d5f5 | ||
|
1cce0defca | ||
|
d05e1d241c | ||
|
b1a7ef89ca | ||
|
d97b3f8da1 | ||
|
0c2eeaf954 | ||
|
b826fb3ce6 | ||
|
b66188cbf9 | ||
|
49eb964ed8 | ||
|
9ee6f318c7 | ||
|
5fe5f09032 | ||
|
03311d7004 | ||
|
56c9270fdb | ||
|
2f34cd20ba | ||
|
b2b3fb37ec | ||
|
b3e191f01a | ||
|
785f94dd9f | ||
|
c92f4c4ba7 | ||
|
575aa01fa5 | ||
|
61f1464e37 | ||
|
93a113206f | ||
|
e0069a9375 | ||
|
eba31f8bda | ||
|
846483ee17 | ||
|
2a4ddaeea5 | ||
|
32ad02a260 | ||
|
3e3ccd971b | ||
|
038de34f8f | ||
|
e10db2fa09 | ||
|
309aa569f6 | ||
|
bbd7623855 |
217 changed files with 13237 additions and 6736 deletions
43
.air.toml
Normal file
43
.air.toml
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
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,3 +12,5 @@ package
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
target
|
target
|
||||||
|
tmp
|
||||||
|
seed.yaml
|
||||||
|
|
13
.woodpecker/.backend.yml
Normal file
13
.woodpecker/.backend.yml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
when:
|
||||||
|
branch:
|
||||||
|
exclude: stable
|
||||||
|
|
||||||
|
steps:
|
||||||
|
check:
|
||||||
|
image: golang:alpine
|
||||||
|
commands:
|
||||||
|
- apk update && apk add curl vips-dev build-base
|
||||||
|
- make backend
|
||||||
|
# Install golangci-lint
|
||||||
|
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2
|
||||||
|
- golangci-lint run
|
20
.woodpecker/.frontend.yml
Normal file
20
.woodpecker/.frontend.yml
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
when:
|
||||||
|
branch:
|
||||||
|
exclude: stable
|
||||||
|
|
||||||
|
steps:
|
||||||
|
check:
|
||||||
|
image: node
|
||||||
|
directory: frontend
|
||||||
|
environment: # SvelteKit expects these in the environment during build time.
|
||||||
|
- PRIVATE_SENTRY_DSN=
|
||||||
|
- PUBLIC_BASE_URL=http://pronouns.localhost
|
||||||
|
- PUBLIC_MEDIA_URL=http://pronouns.localhost/media
|
||||||
|
- PUBLIC_SHORT_BASE=http://prns.localhost
|
||||||
|
- PUBLIC_HCAPTCHA_SITEKEY=non_existent_sitekey
|
||||||
|
commands:
|
||||||
|
- corepack enable
|
||||||
|
- pnpm install
|
||||||
|
- pnpm check
|
||||||
|
- pnpm lint
|
||||||
|
- pnpm build
|
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`" .
|
go build -v -o pronouns -ldflags="-buildid= -X codeberg.org/pronounscc/pronouns.cc/backend/server.Revision=`git rev-parse --short HEAD` -X codeberg.org/pronounscc/pronouns.cc/backend/server.Tag=`git describe --tags --long --always`" .
|
||||||
|
|
||||||
.PHONY: generate
|
.PHONY: generate
|
||||||
generate:
|
generate:
|
||||||
|
|
16
README.md
16
README.md
|
@ -26,19 +26,25 @@ 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. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
|
3. Copy `frontend/.env.example` to `frontend/env` and fill out the required options.
|
||||||
4. Run `go run -v . web` to run the backend.
|
4. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
|
||||||
5. Copy `frontend/.env.example` into `frontend/.env` and tweak as necessary.
|
5. Run `pnpm dev`. Alternatively, if you don't want the backend to live reload, run `go run -v . web`,
|
||||||
6. cd into the `frontend` directory and run `pnpm dev` to run the frontend.
|
then change to the `frontend/` directory and run `pnpm dev`.
|
||||||
|
|
||||||
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>
|
||||||
|
|
65
backend/common/generator.go
Normal file
65
backend/common/generator.go
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
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))
|
||||||
|
}
|
83
backend/common/snowflake.go
Normal file
83
backend/common/snowflake.go
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
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)
|
||||||
|
}
|
39
backend/common/snowflake_types.go
Normal file
39
backend/common/snowflake_types.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
pgxscan.Get(ctx, db, &de, sql, args...)
|
err = pgxscan.Get(ctx, db, &de, sql, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return de, errors.Wrap(err, "executing sql")
|
return de, errors.Wrap(err, "executing sql")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 == "pixelfed" || f.InstanceType == "gotosocial"
|
return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "incestoma" || 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"
|
return f.InstanceType == "misskey" || f.InstanceType == "foundkey" || f.InstanceType == "calckey" || f.InstanceType == "firefish" || f.InstanceType == "sharkey"
|
||||||
}
|
}
|
||||||
|
|
||||||
const ErrNoInstanceApp = errors.Sentinel("instance doesn't have an app")
|
const ErrNoInstanceApp = errors.Sentinel("instance doesn't have an app")
|
||||||
|
|
|
@ -43,7 +43,10 @@ func (f Field) Validate(custom CustomPreferences) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !entry.Status.Valid(custom) {
|
if !entry.Status.Valid(custom) {
|
||||||
return fmt.Sprintf("entries.%d: status is invalid", i)
|
if entry.Status == "missing" {
|
||||||
|
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,6 +9,7 @@ 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"
|
||||||
|
@ -20,11 +21,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type PrideFlag struct {
|
type PrideFlag struct {
|
||||||
ID xid.ID `json:"id"`
|
ID xid.ID `json:"id"`
|
||||||
UserID xid.ID `json:"-"`
|
SnowflakeID common.FlagID `json:"id_new"`
|
||||||
Hash string `json:"hash"`
|
UserID xid.ID `json:"-"`
|
||||||
Name string `json:"name"`
|
Hash string `json:"hash"`
|
||||||
Description *string `json:"description"`
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserFlag struct {
|
type UserFlag struct {
|
||||||
|
@ -194,11 +196,12 @@ 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(),
|
||||||
"hash": "",
|
"snowflake_id": common.GenerateID(),
|
||||||
"user_id": userID.String(),
|
"hash": "",
|
||||||
"name": name,
|
"user_id": userID.String(),
|
||||||
"description": description,
|
"name": name,
|
||||||
|
"description": description,
|
||||||
}).Suffix("RETURNING *").ToSql()
|
}).Suffix("RETURNING *").ToSql()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return f, errors.Wrap(err, "building query")
|
return f, errors.Wrap(err, "building query")
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/georgysavva/scany/v2/pgxscan"
|
"github.com/georgysavva/scany/v2/pgxscan"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
@ -43,7 +44,12 @@ func (db *DB) CreateInvite(ctx context.Context, userID xid.ID) (i Invite, err er
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return i, errors.Wrap(err, "beginning transaction")
|
return i, errors.Wrap(err, "beginning transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
var maxInvites, inviteCount int
|
var maxInvites, inviteCount int
|
||||||
err = tx.QueryRow(ctx, "SELECT max_invites FROM users WHERE id = $1", userID).Scan(&maxInvites)
|
err = tx.QueryRow(ctx, "SELECT max_invites FROM users WHERE id = $1", userID).Scan(&maxInvites)
|
||||||
|
|
|
@ -3,8 +3,11 @@ package db
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"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"
|
||||||
|
@ -21,6 +24,7 @@ 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
|
||||||
|
@ -38,12 +42,22 @@ 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.
|
||||||
|
var invalidMemberNames = []string{
|
||||||
|
// these break routing outright
|
||||||
|
".",
|
||||||
|
"..",
|
||||||
|
// the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible
|
||||||
|
"edit",
|
||||||
|
}
|
||||||
|
|
||||||
func MemberNameValid(name string) bool {
|
func MemberNameValid(name string) bool {
|
||||||
// These two names will break routing, but periods should still be allowed in names otherwise.
|
for i := range invalidMemberNames {
|
||||||
if name == "." || name == ".." {
|
if strings.EqualFold(name, invalidMemberNames[i]) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return memberNameRegex.MatchString(name)
|
return memberNameRegex.MatchString(name)
|
||||||
|
@ -62,9 +76,23 @@ 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) {
|
||||||
sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).Where("(id = ? or name = ?)", memberRef, memberRef).ToSql()
|
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 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")
|
||||||
}
|
}
|
||||||
|
@ -126,8 +154,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", "id", "sid", "name", "display_name", "bio", "links").
|
Columns("user_id", "snowflake_id", "id", "sid", "name", "display_name", "bio", "links").
|
||||||
Values(userID, xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links).
|
Values(userID, common.GenerateID(), 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")
|
||||||
|
@ -260,7 +288,12 @@ func (db *DB) RerollMemberSID(ctx context.Context, userID, memberID xid.ID) (new
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.Wrap(err, "beginning transaction")
|
return "", errors.Wrap(err, "beginning transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
sql, args, err := sq.Update("members").
|
sql, args, err := sq.Update("members").
|
||||||
Set("sid", squirrel.Expr("find_free_member_sid()")).
|
Set("sid", squirrel.Expr("find_free_member_sid()")).
|
||||||
|
|
66
backend/db/notice.go
Normal file
66
backend/db/notice.go
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
|
@ -21,6 +22,7 @@ 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
|
||||||
|
@ -52,6 +54,8 @@ type User struct {
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
ListPrivate bool
|
ListPrivate bool
|
||||||
LastSIDReroll time.Time `db:"last_sid_reroll"`
|
LastSIDReroll time.Time `db:"last_sid_reroll"`
|
||||||
|
Timezone *string
|
||||||
|
Settings UserSettings
|
||||||
|
|
||||||
DeletedAt *time.Time
|
DeletedAt *time.Time
|
||||||
SelfDelete *bool
|
SelfDelete *bool
|
||||||
|
@ -113,6 +117,21 @@ func (u User) NumProviders() (numProviders int) {
|
||||||
return numProviders
|
return numProviders
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UTCOffset returns the user's UTC offset in seconds. If the user does not have a timezone set, `ok` is false.
|
||||||
|
func (u User) UTCOffset() (offset int, ok bool) {
|
||||||
|
if u.Timezone == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, err := time.LoadLocation(*u.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
_, offset = time.Now().In(loc).Zone()
|
||||||
|
return offset, true
|
||||||
|
}
|
||||||
|
|
||||||
type Badge int32
|
type Badge int32
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -122,12 +141,22 @@ const (
|
||||||
// usernames must match this regex
|
// usernames must match this regex
|
||||||
var usernameRegex = regexp.MustCompile(`^[\w-.]{2,40}$`)
|
var usernameRegex = regexp.MustCompile(`^[\w-.]{2,40}$`)
|
||||||
|
|
||||||
func UsernameValid(username string) (err error) {
|
// List of usernames that cannot be used, because they could create confusion, conflict with other pages, or cause bugs.
|
||||||
// This name would break routing, but periods should still be allowed in names otherwise.
|
var invalidUsernames = []string{
|
||||||
if username == ".." {
|
"..",
|
||||||
return ErrInvalidUsername
|
"admin",
|
||||||
}
|
"administrator",
|
||||||
|
"mod",
|
||||||
|
"moderator",
|
||||||
|
"api",
|
||||||
|
"page",
|
||||||
|
"pronouns",
|
||||||
|
"settings",
|
||||||
|
"pronouns.cc",
|
||||||
|
"pronounscc",
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsernameValid(username string) (err error) {
|
||||||
if !usernameRegex.MatchString(username) {
|
if !usernameRegex.MatchString(username) {
|
||||||
if len(username) < 2 {
|
if len(username) < 2 {
|
||||||
return ErrUsernameTooShort
|
return ErrUsernameTooShort
|
||||||
|
@ -137,6 +166,13 @@ func UsernameValid(username string) (err error) {
|
||||||
|
|
||||||
return ErrInvalidUsername
|
return ErrInvalidUsername
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i := range invalidUsernames {
|
||||||
|
if strings.EqualFold(username, invalidUsernames[i]) {
|
||||||
|
return ErrBannedUsername
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,6 +183,7 @@ const (
|
||||||
ErrInvalidUsername = errors.Sentinel("username contains invalid characters")
|
ErrInvalidUsername = errors.Sentinel("username contains invalid characters")
|
||||||
ErrUsernameTooShort = errors.Sentinel("username is too short")
|
ErrUsernameTooShort = errors.Sentinel("username is too short")
|
||||||
ErrUsernameTooLong = errors.Sentinel("username is too long")
|
ErrUsernameTooLong = errors.Sentinel("username is too long")
|
||||||
|
ErrBannedUsername = errors.Sentinel("username is banned")
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -170,7 +207,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", "username", "sid").Values(xid.New(), username, squirrel.Expr("find_free_user_sid()")).Suffix("RETURNING *").ToSql()
|
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()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return u, errors.Wrap(err, "building sql")
|
return u, errors.Wrap(err, "building sql")
|
||||||
}
|
}
|
||||||
|
@ -458,6 +495,26 @@ 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()
|
||||||
|
@ -539,9 +596,10 @@ func (db *DB) UpdateUser(
|
||||||
memberTitle *string, listPrivate *bool,
|
memberTitle *string, listPrivate *bool,
|
||||||
links *[]string,
|
links *[]string,
|
||||||
avatar *string,
|
avatar *string,
|
||||||
|
timezone *string,
|
||||||
customPreferences *CustomPreferences,
|
customPreferences *CustomPreferences,
|
||||||
) (u User, err error) {
|
) (u User, err error) {
|
||||||
if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil && customPreferences == nil {
|
if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil && timezone == nil && customPreferences == nil {
|
||||||
sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql()
|
sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return u, errors.Wrap(err, "building sql")
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
@ -577,6 +635,13 @@ func (db *DB) UpdateUser(
|
||||||
builder = builder.Set("member_title", *memberTitle)
|
builder = builder.Set("member_title", *memberTitle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if timezone != nil {
|
||||||
|
if *timezone == "" {
|
||||||
|
builder = builder.Set("timezone", nil)
|
||||||
|
} else {
|
||||||
|
builder = builder.Set("timezone", *timezone)
|
||||||
|
}
|
||||||
|
}
|
||||||
if links != nil {
|
if links != nil {
|
||||||
builder = builder.Set("links", *links)
|
builder = builder.Set("links", *links)
|
||||||
}
|
}
|
||||||
|
@ -760,3 +825,24 @@ 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
|
||||||
|
}
|
||||||
|
|
27
backend/db/user_settings.go
Normal file
27
backend/db/user_settings.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
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,6 +11,7 @@ import (
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
_ "github.com/joho/godotenv/autoload"
|
_ "github.com/joho/godotenv/autoload"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
|
@ -23,6 +24,19 @@ var Command = &cli.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(c *cli.Context) error {
|
func run(c *cli.Context) error {
|
||||||
|
// initialize sentry
|
||||||
|
if dsn := os.Getenv("SENTRY_DSN"); dsn != "" {
|
||||||
|
// We don't need to check the error here--it's fine if no DSN is set.
|
||||||
|
_ = sentry.Init(sentry.ClientOptions{
|
||||||
|
Dsn: dsn,
|
||||||
|
Debug: os.Getenv("DEBUG") == "true",
|
||||||
|
Release: server.Tag,
|
||||||
|
EnableTracing: os.Getenv("SENTRY_TRACING") == "true",
|
||||||
|
TracesSampleRate: 0.05,
|
||||||
|
ProfilesSampleRate: 0.05,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// set vips log level to WARN, else it will spam logs on info level
|
// set vips log level to WARN, else it will spam logs on info level
|
||||||
vips.LoggingSettings(nil, vips.LogLevelWarning)
|
vips.LoggingSettings(nil, vips.LogLevelWarning)
|
||||||
|
|
||||||
|
@ -62,9 +76,8 @@ 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
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -1,39 +1,30 @@
|
||||||
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/member"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/routes/auth"
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/meta"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/routes/bot"
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/mod"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/routes/member"
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/user"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/routes/meta"
|
user2 "codeberg.org/pronounscc/pronouns.cc/backend/routes/v2/user"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/routes/mod"
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/routes/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)
|
||||||
})
|
})
|
||||||
|
|
||||||
// API docs
|
s.Router.Route("/v2", func(r chi.Router) {
|
||||||
s.Router.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
user2.Mount(s, r)
|
||||||
render.HTML(w, r, openapi)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,183 +0,0 @@
|
||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/ed25519"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
|
||||||
"github.com/bwmarrin/discordgo"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/render"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Bot struct {
|
|
||||||
*server.Server
|
|
||||||
|
|
||||||
publicKey ed25519.PublicKey
|
|
||||||
baseURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bot *Bot) UserAvatarURL(u db.User) string {
|
|
||||||
if u.Avatar == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return bot.baseURL + "/media/users/" + u.ID.String() + "/" + *u.Avatar + ".webp"
|
|
||||||
}
|
|
||||||
|
|
||||||
func Mount(srv *server.Server, r chi.Router) {
|
|
||||||
publicKey, err := hex.DecodeString(os.Getenv("DISCORD_PUBLIC_KEY"))
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
b := &Bot{
|
|
||||||
Server: srv,
|
|
||||||
publicKey: publicKey,
|
|
||||||
baseURL: os.Getenv("BASE_URL"),
|
|
||||||
}
|
|
||||||
|
|
||||||
r.HandleFunc("/interactions", b.handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bot *Bot) handle(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !discordgo.VerifyInteraction(r, bot.publicKey) {
|
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var ev *discordgo.InteractionCreate
|
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&ev); err != nil {
|
|
||||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
// we can always respond to ping with pong
|
|
||||||
if ev.Type == discordgo.InteractionPing {
|
|
||||||
log.Debug("received ping interaction")
|
|
||||||
render.JSON(w, r, discordgo.InteractionResponse{
|
|
||||||
Type: discordgo.InteractionResponsePong,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if ev.Type != discordgo.InteractionApplicationCommand {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data := ev.ApplicationCommandData()
|
|
||||||
|
|
||||||
switch data.Name {
|
|
||||||
case "Show user's pronouns":
|
|
||||||
bot.userPronouns(w, r, ev)
|
|
||||||
case "Show author's pronouns":
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bot *Bot) userPronouns(w http.ResponseWriter, r *http.Request, ev *discordgo.InteractionCreate) {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
var du *discordgo.User
|
|
||||||
for _, user := range ev.ApplicationCommandData().Resolved.Users {
|
|
||||||
du = user
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if du == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := bot.DB.DiscordUser(ctx, du.ID)
|
|
||||||
if err != nil {
|
|
||||||
if err == db.ErrUserNotFound {
|
|
||||||
respond(w, r, &discordgo.MessageEmbed{
|
|
||||||
Description: du.String() + " does not have any pronouns set.",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Errorf("getting discord user: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
avatarURL := du.AvatarURL("")
|
|
||||||
if url := bot.UserAvatarURL(u); url != "" {
|
|
||||||
avatarURL = url
|
|
||||||
}
|
|
||||||
name := u.Username
|
|
||||||
if u.DisplayName != nil {
|
|
||||||
name = fmt.Sprintf("%s (%s)", *u.DisplayName, u.Username)
|
|
||||||
}
|
|
||||||
url := bot.baseURL
|
|
||||||
if url != "" {
|
|
||||||
url += "/@" + u.Username
|
|
||||||
}
|
|
||||||
|
|
||||||
e := &discordgo.MessageEmbed{
|
|
||||||
Author: &discordgo.MessageEmbedAuthor{
|
|
||||||
Name: name,
|
|
||||||
IconURL: avatarURL,
|
|
||||||
URL: url,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.Bio != nil {
|
|
||||||
e.Fields = append(e.Fields, &discordgo.MessageEmbedField{
|
|
||||||
Name: "Bio",
|
|
||||||
Value: *u.Bio,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fields, err := bot.DB.UserFields(ctx, u.ID)
|
|
||||||
if err != nil {
|
|
||||||
respond(w, r, e)
|
|
||||||
|
|
||||||
log.Errorf("getting user fields: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, field := range fields {
|
|
||||||
var favs []db.FieldEntry
|
|
||||||
|
|
||||||
for _, e := range field.Entries {
|
|
||||||
if e.Status == "favourite" {
|
|
||||||
favs = append(favs, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(favs) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var value string
|
|
||||||
for _, fav := range favs {
|
|
||||||
if len(fav.Value) > 500 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
value += fav.Value + "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
e.Fields = append(e.Fields, &discordgo.MessageEmbedField{
|
|
||||||
Name: field.Name,
|
|
||||||
Value: value,
|
|
||||||
Inline: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
respond(w, r, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
func respond(w http.ResponseWriter, r *http.Request, embeds ...*discordgo.MessageEmbed) {
|
|
||||||
render.JSON(w, r, discordgo.InteractionResponse{
|
|
||||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
|
||||||
Data: &discordgo.InteractionResponseData{
|
|
||||||
Embeds: embeds,
|
|
||||||
Flags: discordgo.MessageFlagsEphemeral,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/mediocregopher/radix/v4"
|
"github.com/mediocregopher/radix/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
@ -61,7 +62,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
// if the state can't be validated, return
|
// if the state can't be validated, return
|
||||||
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "validating state")
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.APIError{Code: server.ErrInvalidState}
|
return server.APIError{Code: server.ErrInvalidState}
|
||||||
|
@ -79,7 +80,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
dg, _ := discordgo.New(token.Type() + " " + token.AccessToken)
|
dg, _ := discordgo.New(token.Type() + " " + token.AccessToken)
|
||||||
du, err := dg.User("@me")
|
du, err := dg.User("@me")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "getting discord user")
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := s.DB.DiscordUser(ctx, du.ID)
|
u, err := s.DB.DiscordUser(ctx, du.ID)
|
||||||
|
@ -90,7 +91,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.saveUndeleteToken(ctx, u.ID, token)
|
err = s.saveUndeleteToken(ctx, u.ID, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("saving undelete token: %v", err)
|
log.Errorf("saving undelete token: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "saving undelete token")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, discordCallbackResponse{
|
render.JSON(w, r, discordCallbackResponse{
|
||||||
|
@ -114,7 +115,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
tokenID := xid.New()
|
tokenID := xid.New()
|
||||||
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "creating token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// save token to database
|
// save token to database
|
||||||
|
@ -137,7 +138,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
} else if err != db.ErrUserNotFound { // internal error
|
} else if err != db.ErrUserNotFound { // internal error
|
||||||
return err
|
return errors.Wrap(err, "getting user")
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Discord info in Redis
|
// no user found, so save a ticket + save their Discord info in Redis
|
||||||
|
@ -145,7 +146,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600")
|
err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting Discord user for ticket %q: %v", ticket, err)
|
log.Errorf("setting Discord user for ticket %q: %v", ticket, err)
|
||||||
return err
|
return errors.Wrap(err, "caching discord user for ticket")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, discordCallbackResponse{
|
render.JSON(w, r, discordCallbackResponse{
|
||||||
|
@ -278,7 +279,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "checking if username is taken")
|
||||||
}
|
}
|
||||||
if !valid {
|
if !valid {
|
||||||
return server.APIError{Code: server.ErrInvalidUsername}
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
@ -291,7 +292,12 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "beginning transaction")
|
return errors.Wrap(err, "beginning transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
du := new(discordgo.User)
|
du := new(discordgo.User)
|
||||||
err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du)
|
err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du)
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/mediocregopher/radix/v4"
|
"github.com/mediocregopher/radix/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
@ -54,7 +55,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
||||||
// if the state can't be validated, return
|
// if the state can't be validated, return
|
||||||
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "validating state")
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.APIError{Code: server.ErrInvalidState}
|
return server.APIError{Code: server.ErrInvalidState}
|
||||||
|
@ -111,7 +112,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
||||||
err = s.saveUndeleteToken(ctx, u.ID, token)
|
err = s.saveUndeleteToken(ctx, u.ID, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("saving undelete token: %v", err)
|
log.Errorf("saving undelete token: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "saving undelete token")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, fediCallbackResponse{
|
render.JSON(w, r, fediCallbackResponse{
|
||||||
|
@ -135,7 +136,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
||||||
tokenID := xid.New()
|
tokenID := xid.New()
|
||||||
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "creating token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// save token to database
|
// save token to database
|
||||||
|
@ -158,7 +159,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
} else if err != db.ErrUserNotFound { // internal error
|
} else if err != db.ErrUserNotFound { // internal error
|
||||||
return err
|
return errors.Wrap(err, "getting user")
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Mastodon info in Redis
|
// no user found, so save a ticket + save their Mastodon info in Redis
|
||||||
|
@ -166,7 +167,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
||||||
err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600")
|
err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err)
|
log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err)
|
||||||
return err
|
return errors.Wrap(err, "setting user for ticket")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, fediCallbackResponse{
|
render.JSON(w, r, fediCallbackResponse{
|
||||||
|
@ -306,7 +307,7 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "checking if username is taken")
|
||||||
}
|
}
|
||||||
if !valid {
|
if !valid {
|
||||||
return server.APIError{Code: server.ErrInvalidUsername}
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
@ -319,7 +320,12 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "beginning transaction")
|
return errors.Wrap(err, "beginning transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
mu := new(partialMastodonAccount)
|
mu := new(partialMastodonAccount)
|
||||||
err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu)
|
err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu)
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/mediocregopher/radix/v4"
|
"github.com/mediocregopher/radix/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
@ -90,7 +91,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.saveUndeleteToken(ctx, u.ID, token)
|
err = s.saveUndeleteToken(ctx, u.ID, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("saving undelete token: %v", err)
|
log.Errorf("saving undelete token: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "saving undelete token")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, fediCallbackResponse{
|
render.JSON(w, r, fediCallbackResponse{
|
||||||
|
@ -114,7 +115,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
tokenID := xid.New()
|
tokenID := xid.New()
|
||||||
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "creating token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// save token to database
|
// save token to database
|
||||||
|
@ -137,7 +138,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
} else if err != db.ErrUserNotFound { // internal error
|
} else if err != db.ErrUserNotFound { // internal error
|
||||||
return err
|
return errors.Wrap(err, "getting user")
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Misskey info in Redis
|
// no user found, so save a ticket + save their Misskey info in Redis
|
||||||
|
@ -145,7 +146,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu.User, "EX", "600")
|
err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu.User, "EX", "600")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting misskey user for ticket %q: %v", ticket, err)
|
log.Errorf("setting misskey user for ticket %q: %v", ticket, err)
|
||||||
return err
|
return errors.Wrap(err, "setting user for ticket")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, fediCallbackResponse{
|
render.JSON(w, r, fediCallbackResponse{
|
||||||
|
@ -234,7 +235,7 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "checking if username is taken")
|
||||||
}
|
}
|
||||||
if !valid {
|
if !valid {
|
||||||
return server.APIError{Code: server.ErrInvalidUsername}
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
@ -247,7 +248,12 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "beginning transaction")
|
return errors.Wrap(err, "beginning transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
mu := new(partialMisskeyAccount)
|
mu := new(partialMisskeyAccount)
|
||||||
err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu)
|
err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu)
|
|
@ -65,12 +65,13 @@ func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r
|
||||||
}
|
}
|
||||||
|
|
||||||
switch softwareName {
|
switch softwareName {
|
||||||
case "misskey", "foundkey", "calckey":
|
case "iceshrimp":
|
||||||
|
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", "pixelfed", "gotosocial":
|
case "mastodon", "pleroma", "akkoma", "incestoma", "pixelfed", "gotosocial":
|
||||||
case "glitchcafe":
|
case "glitchcafe", "hometown":
|
||||||
// plural.cafe (potentially other instances too?) runs Mastodon but changes the software name
|
|
||||||
// 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,6 +10,7 @@ import (
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/mediocregopher/radix/v4"
|
"github.com/mediocregopher/radix/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
@ -60,7 +61,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
// if the state can't be validated, return
|
// if the state can't be validated, return
|
||||||
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "validating state")
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.APIError{Code: server.ErrInvalidState}
|
return server.APIError{Code: server.ErrInvalidState}
|
||||||
|
@ -109,7 +110,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.saveUndeleteToken(ctx, u.ID, token)
|
err = s.saveUndeleteToken(ctx, u.ID, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("saving undelete token: %v", err)
|
log.Errorf("saving undelete token: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "saving undelete token")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, googleCallbackResponse{
|
render.JSON(w, r, googleCallbackResponse{
|
||||||
|
@ -133,7 +134,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
tokenID := xid.New()
|
tokenID := xid.New()
|
||||||
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "creating token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// save token to database
|
// save token to database
|
||||||
|
@ -156,7 +157,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
} else if err != db.ErrUserNotFound { // internal error
|
} else if err != db.ErrUserNotFound { // internal error
|
||||||
return err
|
return errors.Wrap(err, "getting user")
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Google info in Redis
|
// no user found, so save a ticket + save their Google info in Redis
|
||||||
|
@ -164,7 +165,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600")
|
err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting Google user for ticket %q: %v", ticket, err)
|
log.Errorf("setting Google user for ticket %q: %v", ticket, err)
|
||||||
return err
|
return errors.Wrap(err, "setting user for ticket")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, googleCallbackResponse{
|
render.JSON(w, r, googleCallbackResponse{
|
||||||
|
@ -281,7 +282,7 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "checking if username is taken")
|
||||||
}
|
}
|
||||||
if !valid {
|
if !valid {
|
||||||
return server.APIError{Code: server.ErrInvalidUsername}
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
@ -294,7 +295,12 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "beginning transaction")
|
return errors.Wrap(err, "beginning transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
gu := new(partialGoogleUser)
|
gu := new(partialGoogleUser)
|
||||||
err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu)
|
err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu)
|
|
@ -4,6 +4,7 @@ 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"
|
||||||
|
@ -25,6 +26,7 @@ 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"`
|
||||||
|
@ -51,6 +53,7 @@ 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,
|
||||||
|
@ -182,7 +185,7 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
|
||||||
if googleOAuthConfig.ClientID != "" {
|
if googleOAuthConfig.ClientID != "" {
|
||||||
googleCfg := googleOAuthConfig
|
googleCfg := googleOAuthConfig
|
||||||
googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google"
|
googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google"
|
||||||
resp.Google = googleCfg.AuthCodeURL(state)
|
resp.Google = googleCfg.AuthCodeURL(state) + "&prompt=select_account"
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, resp)
|
render.JSON(w, r, resp)
|
|
@ -5,9 +5,11 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -63,7 +65,12 @@ func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "beginning transaction")
|
return errors.Wrap(err, "beginning transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
err = s.DB.InvalidateAllTokens(ctx, tx, claims.UserID)
|
err = s.DB.InvalidateAllTokens(ctx, tx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/mediocregopher/radix/v4"
|
"github.com/mediocregopher/radix/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
@ -77,7 +78,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
// if the state can't be validated, return
|
// if the state can't be validated, return
|
||||||
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "validating state")
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.APIError{Code: server.ErrInvalidState}
|
return server.APIError{Code: server.ErrInvalidState}
|
||||||
|
@ -142,7 +143,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.saveUndeleteToken(ctx, u.ID, token)
|
err = s.saveUndeleteToken(ctx, u.ID, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("saving undelete token: %v", err)
|
log.Errorf("saving undelete token: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "saving undelete token")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, tumblrCallbackResponse{
|
render.JSON(w, r, tumblrCallbackResponse{
|
||||||
|
@ -166,7 +167,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
tokenID := xid.New()
|
tokenID := xid.New()
|
||||||
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "creating token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// save token to database
|
// save token to database
|
||||||
|
@ -189,7 +190,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
} else if err != db.ErrUserNotFound { // internal error
|
} else if err != db.ErrUserNotFound { // internal error
|
||||||
return err
|
return errors.Wrap(err, "getting user")
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Tumblr info in Redis
|
// no user found, so save a ticket + save their Tumblr info in Redis
|
||||||
|
@ -197,7 +198,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600")
|
err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err)
|
log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err)
|
||||||
return err
|
return errors.Wrap(err, "setting user for ticket")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, tumblrCallbackResponse{
|
render.JSON(w, r, tumblrCallbackResponse{
|
||||||
|
@ -314,7 +315,7 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "checking if username is taken")
|
||||||
}
|
}
|
||||||
if !valid {
|
if !valid {
|
||||||
return server.APIError{Code: server.ErrInvalidUsername}
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
@ -327,7 +328,12 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "beginning transaction")
|
return errors.Wrap(err, "beginning transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
tui := new(tumblrUserInfo)
|
tui := new(tumblrUserInfo)
|
||||||
err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui)
|
err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui)
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CreateMemberRequest struct {
|
type CreateMemberRequest struct {
|
||||||
|
@ -119,7 +120,12 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "starting transaction")
|
return errors.Wrap(err, "starting transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links)
|
m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -127,14 +133,14 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
||||||
return server.APIError{Code: server.ErrMemberNameInUse}
|
return server.APIError{Code: server.ErrMemberNameInUse}
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return errors.Wrap(err, "creating member")
|
||||||
}
|
}
|
||||||
|
|
||||||
// set names, pronouns, fields
|
// set names, pronouns, fields
|
||||||
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns))
|
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting names and pronouns for member %v: %v", m.ID, err)
|
log.Errorf("setting names and pronouns for member %v: %v", m.ID, err)
|
||||||
return err
|
return errors.Wrap(err, "setting names/pronouns")
|
||||||
}
|
}
|
||||||
m.Names = cmr.Names
|
m.Names = cmr.Names
|
||||||
m.Pronouns = cmr.Pronouns
|
m.Pronouns = cmr.Pronouns
|
||||||
|
@ -142,7 +148,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
||||||
err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields)
|
err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting fields for member %v: %v", m.ID, err)
|
log.Errorf("setting fields for member %v: %v", m.ID, err)
|
||||||
return err
|
return errors.Wrap(err, "setting fields")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cmr.Avatar != "" {
|
if cmr.Avatar != "" {
|
||||||
|
@ -161,13 +167,13 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Errorf("converting member avatar: %v", err)
|
log.Errorf("converting member avatar: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "converting avatar")
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
|
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("uploading member avatar: %v", err)
|
log.Errorf("uploading member avatar: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "uploading avatar")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar)
|
err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar)
|
||||||
|
@ -180,7 +186,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
||||||
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
|
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
|
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
|
||||||
return err
|
return errors.Wrap(err, "updating last active time")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit(ctx)
|
err = tx.Commit(ctx)
|
|
@ -8,12 +8,13 @@ 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) error {
|
func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
@ -22,18 +23,27 @@ func (s *Server) deleteMember(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"))
|
var m db.Member
|
||||||
if err != nil {
|
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
|
||||||
return server.APIError{Code: server.ErrMemberNotFound}
|
m, err = s.DB.Member(ctx, id)
|
||||||
}
|
if err != nil {
|
||||||
|
if err == db.ErrMemberNotFound {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
m, err := s.DB.Member(ctx, id)
|
return errors.Wrap(err, "getting member")
|
||||||
if err != nil {
|
|
||||||
if err == db.ErrMemberNotFound {
|
|
||||||
return server.APIError{Code: server.ErrMemberNotFound}
|
|
||||||
}
|
}
|
||||||
|
} 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")
|
return errors.Wrap(err, "getting member")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.UserID != claims.UserID {
|
if m.UserID != claims.UserID {
|
||||||
|
@ -56,7 +66,7 @@ func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.DB.UpdateActiveTime(ctx, s.DB, claims.UserID)
|
err = s.DB.UpdateActiveTime(ctx, s.DB, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
|
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
|
||||||
return err
|
return errors.Wrap(err, "updating last active time")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.NoContent(w, r)
|
render.NoContent(w, r)
|
|
@ -4,7 +4,9 @@ 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"
|
||||||
|
@ -13,13 +15,14 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type GetMemberResponse struct {
|
type GetMemberResponse struct {
|
||||||
ID xid.ID `json:"id"`
|
ID xid.ID `json:"id"`
|
||||||
SID string `json:"sid"`
|
SnowflakeID common.MemberID `json:"id_new"`
|
||||||
Name string `json:"name"`
|
SID string `json:"sid"`
|
||||||
DisplayName *string `json:"display_name"`
|
Name string `json:"name"`
|
||||||
Bio *string `json:"bio"`
|
DisplayName *string `json:"display_name"`
|
||||||
Avatar *string `json:"avatar"`
|
Bio *string `json:"bio"`
|
||||||
Links []string `json:"links"`
|
Avatar *string `json:"avatar"`
|
||||||
|
Links []string `json:"links"`
|
||||||
|
|
||||||
Names []db.FieldEntry `json:"names"`
|
Names []db.FieldEntry `json:"names"`
|
||||||
Pronouns []db.PronounEntry `json:"pronouns"`
|
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||||
|
@ -34,6 +37,7 @@ 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,
|
||||||
|
@ -48,6 +52,7 @@ 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,
|
||||||
|
@ -64,32 +69,43 @@ 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) error {
|
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
|
var m db.Member
|
||||||
if err != nil {
|
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
|
||||||
return server.APIError{
|
m, err = s.DB.Member(ctx, id)
|
||||||
Code: server.ErrMemberNotFound,
|
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 {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrMemberNotFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
m, err := s.DB.Member(ctx, id)
|
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(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 err
|
return errors.Wrap(err, "getting user")
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.DeletedAt != nil {
|
if u.DeletedAt != nil {
|
||||||
|
@ -103,12 +119,12 @@ func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
fields, err := s.DB.MemberFields(ctx, m.ID)
|
fields, err := s.DB.MemberFields(ctx, m.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "getting member fields")
|
||||||
}
|
}
|
||||||
|
|
||||||
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "getting member flags")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
|
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
|
||||||
|
@ -143,12 +159,12 @@ func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
fields, err := s.DB.MemberFields(ctx, m.ID)
|
fields, err := s.DB.MemberFields(ctx, m.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "getting member fields")
|
||||||
}
|
}
|
||||||
|
|
||||||
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "getting member flags")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
|
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
|
||||||
|
@ -173,12 +189,12 @@ func (s *Server) getMeMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
fields, err := s.DB.MemberFields(ctx, m.ID)
|
fields, err := s.DB.MemberFields(ctx, m.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "getting member fields")
|
||||||
}
|
}
|
||||||
|
|
||||||
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "getting member flags")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))
|
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))
|
||||||
|
@ -186,12 +202,22 @@ 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) {
|
||||||
if id, err := xid.FromString(userRef); err != nil {
|
// check xid first
|
||||||
|
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,8 +3,10 @@ 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"
|
||||||
|
@ -12,6 +14,7 @@ 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"`
|
||||||
|
@ -28,6 +31,7 @@ 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,
|
||||||
|
@ -71,7 +75,7 @@ func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
ms, err := s.DB.UserMembers(ctx, u.ID, isSelf)
|
ms, err := s.DB.UserMembers(ctx, u.ID, isSelf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "getting members")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, membersToMemberList(ms, isSelf))
|
render.JSON(w, r, membersToMemberList(ms, isSelf))
|
||||||
|
@ -84,7 +88,7 @@ func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
ms, err := s.DB.UserMembers(ctx, claims.UserID, true)
|
ms, err := s.DB.UserMembers(ctx, claims.UserID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errors.Wrap(err, "getting members")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, membersToMemberList(ms, true))
|
render.JSON(w, r, membersToMemberList(ms, true))
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -38,23 +39,37 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
m, err := s.DB.Member(ctx, id)
|
var m db.Member
|
||||||
if err != nil {
|
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
|
||||||
if err == db.ErrMemberNotFound {
|
log.Debugf("%v/%v is xid", chi.URLParam(r, "memberRef"), id)
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
|
||||||
|
if err != nil {
|
||||||
return server.APIError{Code: server.ErrMemberNotFound}
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors.Wrap(err, "getting member")
|
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 {
|
||||||
|
@ -206,13 +221,13 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Errorf("converting member avatar: %v", err)
|
log.Errorf("converting member avatar: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "converting member avatar")
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
|
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("uploading member avatar: %v", err)
|
log.Errorf("uploading member avatar: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "writing member avatar")
|
||||||
}
|
}
|
||||||
avatarHash = &hash
|
avatarHash = &hash
|
||||||
|
|
||||||
|
@ -230,11 +245,16 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
tx, err := s.DB.Begin(ctx)
|
tx, err := s.DB.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("creating transaction: %v", err)
|
log.Errorf("creating transaction: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "creating transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
m, err = s.DB.UpdateMember(ctx, tx, id, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash)
|
m, err = s.DB.UpdateMember(ctx, tx, m.ID, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch errors.Cause(err) {
|
switch errors.Cause(err) {
|
||||||
case db.ErrNothingToUpdate:
|
case db.ErrNothingToUpdate:
|
||||||
|
@ -258,10 +278,10 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
pronouns = *req.Pronouns
|
pronouns = *req.Pronouns
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.DB.SetMemberNamesPronouns(ctx, tx, id, names, pronouns)
|
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, names, pronouns)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting names for member %v: %v", id, err)
|
log.Errorf("setting names for member %v: %v", m.ID, err)
|
||||||
return err
|
return errors.Wrap(err, "setting names/pronouns")
|
||||||
}
|
}
|
||||||
m.Names = names
|
m.Names = names
|
||||||
m.Pronouns = pronouns
|
m.Pronouns = pronouns
|
||||||
|
@ -269,17 +289,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, id, *req.Fields)
|
err = s.DB.SetMemberFields(ctx, tx, m.ID, *req.Fields)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting fields for member %v: %v", id, err)
|
log.Errorf("setting fields for member %v: %v", m.ID, err)
|
||||||
return err
|
return errors.Wrap(err, "setting fields")
|
||||||
}
|
}
|
||||||
fields = *req.Fields
|
fields = *req.Fields
|
||||||
} else {
|
} else {
|
||||||
fields, err = s.DB.MemberFields(ctx, id)
|
fields, err = s.DB.MemberFields(ctx, m.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("getting fields for member %v: %v", id, err)
|
log.Errorf("getting fields for member %v: %v", m.ID, err)
|
||||||
return err
|
return errors.Wrap(err, "getting fields")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -292,7 +312,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Errorf("updating flags for member %v: %v", m.ID, err)
|
log.Errorf("updating flags for member %v: %v", m.ID, err)
|
||||||
return err
|
return errors.Wrap(err, "updating flags")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -300,20 +320,20 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
|
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
|
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
|
||||||
return err
|
return errors.Wrap(err, "updating last active time")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit(ctx)
|
err = tx.Commit(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("committing transaction: %v", err)
|
log.Errorf("committing transaction: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "committing transaction")
|
||||||
}
|
}
|
||||||
|
|
||||||
// get flags to return (we need to return full flag objects, not the array of IDs in the request body)
|
// get flags to return (we need to return full flag objects, not the array of IDs in the request body)
|
||||||
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("getting user flags: %v", err)
|
log.Errorf("getting user flags: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "getting flags")
|
||||||
}
|
}
|
||||||
|
|
||||||
// echo the updated member back on success
|
// echo the updated member back on success
|
||||||
|
@ -321,7 +341,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) error {
|
func (s *Server) rerollMemberSID(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
@ -330,9 +350,32 @@ func (s *Server) rerollMemberSID(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"))
|
var m db.Member
|
||||||
if err != nil {
|
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
|
||||||
return server.APIError{Code: server.ErrMemberNotFound}
|
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 {
|
||||||
|
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)
|
||||||
|
@ -340,15 +383,6 @@ func (s *Server) rerollMemberSID(w http.ResponseWriter, r *http.Request) error {
|
||||||
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,6 +4,8 @@ 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"
|
||||||
|
@ -20,11 +22,17 @@ func Mount(srv *server.Server, r chi.Router) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type MetaResponse struct {
|
type MetaResponse struct {
|
||||||
GitRepository string `json:"git_repository"`
|
GitRepository string `json:"git_repository"`
|
||||||
GitCommit string `json:"git_commit"`
|
GitCommit string `json:"git_commit"`
|
||||||
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 {
|
||||||
|
@ -39,6 +47,18 @@ 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,
|
||||||
|
@ -50,6 +70,7 @@ 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,6 +3,7 @@ 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"
|
||||||
|
@ -18,7 +19,7 @@ type CreateReportRequest struct {
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
@ -26,19 +27,32 @@ func (s *Server) createUserReport(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"}
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := xid.FromString(chi.URLParam(r, "id"))
|
var u db.User
|
||||||
if err != nil {
|
if id, err := xid.FromString(chi.URLParam(r, "id")); err == nil {
|
||||||
return server.APIError{Code: server.ErrBadRequest, Details: "Invalid user ID"}
|
u, err = s.DB.User(ctx, id)
|
||||||
}
|
if err != nil {
|
||||||
|
if err == db.ErrUserNotFound {
|
||||||
|
return server.APIError{Code: server.ErrUserNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
u, err := s.DB.User(ctx, userID)
|
log.Errorf("getting user %v: %v", id, err)
|
||||||
if err != nil {
|
return errors.Wrap(err, "getting user")
|
||||||
if err == db.ErrUserNotFound {
|
}
|
||||||
|
} else {
|
||||||
|
id, err := common.ParseSnowflake(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
return server.APIError{Code: server.ErrUserNotFound}
|
return server.APIError{Code: server.ErrUserNotFound}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Errorf("getting user %v: %v", userID, err)
|
u, err = s.DB.UserBySnowflake(ctx, common.UserID(id))
|
||||||
return errors.Wrap(err, "getting user")
|
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 {
|
||||||
|
@ -73,19 +87,32 @@ 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"}
|
||||||
}
|
}
|
||||||
|
|
||||||
memberID, err := xid.FromString(chi.URLParam(r, "id"))
|
var m db.Member
|
||||||
if err != nil {
|
if id, err := xid.FromString(chi.URLParam(r, "id")); err == nil {
|
||||||
return server.APIError{Code: server.ErrBadRequest, Details: "Invalid member ID"}
|
m, err = s.DB.Member(ctx, id)
|
||||||
}
|
if err != nil {
|
||||||
|
if err == db.ErrMemberNotFound {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
m, err := s.DB.Member(ctx, memberID)
|
log.Errorf("getting user %v: %v", id, err)
|
||||||
if err != nil {
|
return errors.Wrap(err, "getting user")
|
||||||
if err == db.ErrMemberNotFound {
|
}
|
||||||
return server.APIError{Code: server.ErrMemberNotFound}
|
} else {
|
||||||
|
id, err := common.ParseSnowflake(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrUserNotFound}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Errorf("getting member %v: %v", memberID, err)
|
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
|
||||||
return errors.Wrap(err, "getting member")
|
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)
|
55
backend/routes/v1/mod/notices.go
Normal file
55
backend/routes/v1/mod/notices.go
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
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,6 +10,7 @@ import (
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type resolveReportRequest struct {
|
type resolveReportRequest struct {
|
||||||
|
@ -43,7 +44,12 @@ func (s *Server) resolveReport(w http.ResponseWriter, r *http.Request) error {
|
||||||
log.Errorf("creating transaction: %v", err)
|
log.Errorf("creating transaction: %v", err)
|
||||||
return errors.Wrap(err, "creating transaction")
|
return errors.Wrap(err, "creating transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
report, err := s.DB.Report(ctx, tx, id)
|
report, err := s.DB.Report(ctx, tx, id)
|
||||||
if err != nil {
|
if err != nil {
|
|
@ -22,6 +22,8 @@ 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,9 +3,11 @@ package user
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
@ -20,7 +22,12 @@ func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "creating transaction")
|
return errors.Wrap(err, "creating transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
err = s.DB.DeleteUser(ctx, tx, claims.UserID, true, "")
|
err = s.DB.DeleteUser(ctx, tx, claims.UserID, true, "")
|
||||||
if err != nil {
|
if err != nil {
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -71,7 +72,7 @@ func (s *Server) getExport(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Errorf("getting export for user %v: %v", claims.UserID, err)
|
log.Errorf("getting export for user %v: %v", claims.UserID, err)
|
||||||
return err
|
return errors.Wrap(err, "getting export")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, dataExportResponse{
|
render.JSON(w, r, dataExportResponse{
|
|
@ -1,6 +1,7 @@
|
||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -12,6 +13,7 @@ import (
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -79,7 +81,12 @@ func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "starting transaction")
|
return errors.Wrap(err, "starting transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
flag, err := s.DB.CreateFlag(ctx, tx, claims.UserID, req.Name, req.Description)
|
flag, err := s.DB.CreateFlag(ctx, tx, claims.UserID, req.Name, req.Description)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -121,6 +128,24 @@ 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)
|
||||||
|
@ -129,28 +154,13 @@ 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 {
|
|
||||||
return server.APIError{
|
flag, ok := s.parseFlag(ctx, flags, chi.URLParam(r, "flagID"))
|
||||||
Code: server.ErrFlagLimitReached,
|
if !ok {
|
||||||
}
|
|
||||||
}
|
|
||||||
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"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,9 +198,14 @@ func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "beginning transaction")
|
return errors.Wrap(err, "beginning transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
flag, err := s.DB.EditFlag(ctx, tx, flagID, req.Name, req.Description, nil)
|
flag, err = s.DB.EditFlag(ctx, tx, flag.ID, req.Name, req.Description, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "updating flag")
|
return errors.Wrap(err, "updating flag")
|
||||||
}
|
}
|
||||||
|
@ -212,19 +227,16 @@ 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"}
|
||||||
}
|
}
|
||||||
|
|
||||||
flagID, err := xid.FromString(chi.URLParam(r, "flagID"))
|
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"}
|
return errors.Wrap(err, "getting current user flags")
|
||||||
}
|
}
|
||||||
|
|
||||||
flag, err := s.DB.UserFlag(ctx, flagID)
|
flag, ok := s.parseFlag(ctx, flags, chi.URLParam(r, "flagID"))
|
||||||
if err != nil {
|
if !ok {
|
||||||
if err == db.ErrFlagNotFound {
|
return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"}
|
||||||
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,9 +4,11 @@ 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"
|
||||||
|
@ -14,6 +16,7 @@ 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"`
|
||||||
|
@ -28,12 +31,14 @@ type GetUserResponse struct {
|
||||||
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
|
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
|
||||||
Flags []db.UserFlag `json:"flags"`
|
Flags []db.UserFlag `json:"flags"`
|
||||||
Badges db.Badge `json:"badges"`
|
Badges db.Badge `json:"badges"`
|
||||||
|
UTCOffset *int `json:"utc_offset"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetMeResponse struct {
|
type GetMeResponse struct {
|
||||||
GetUserResponse
|
GetUserResponse
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Timezone *string `json:"timezone"`
|
||||||
|
|
||||||
MaxInvites int `json:"max_invites"`
|
MaxInvites int `json:"max_invites"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
@ -56,6 +61,7 @@ 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"`
|
||||||
|
@ -69,6 +75,7 @@ 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,
|
||||||
|
@ -87,10 +94,15 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags [
|
||||||
resp.Badges |= db.BadgeAdmin
|
resp.Badges |= db.BadgeAdmin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if offset, ok := u.UTCOffset(); ok {
|
||||||
|
resp.UTCOffset = &offset
|
||||||
|
}
|
||||||
|
|
||||||
resp.Members = make([]PartialMember, len(members))
|
resp.Members = make([]PartialMember, len(members))
|
||||||
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,
|
||||||
|
@ -118,6 +130,15 @@ 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 {
|
||||||
|
@ -126,7 +147,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
}
|
}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
log.Errorf("Error getting user by username: %v", err)
|
log.Errorf("Error getting user by username: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "getting user")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -142,13 +163,13 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
fields, err := s.DB.UserFields(ctx, u.ID)
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Error getting user fields: %v", err)
|
log.Errorf("Error getting user fields: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "getting fields")
|
||||||
}
|
}
|
||||||
|
|
||||||
flags, err := s.DB.UserFlags(ctx, u.ID)
|
flags, err := s.DB.UserFlags(ctx, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("getting user flags: %v", err)
|
log.Errorf("getting user flags: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "getting flags")
|
||||||
}
|
}
|
||||||
|
|
||||||
var members []db.Member
|
var members []db.Member
|
||||||
|
@ -156,7 +177,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
members, err = s.DB.UserMembers(ctx, u.ID, isSelf)
|
members, err = s.DB.UserMembers(ctx, u.ID, isSelf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Error getting user members: %v", err)
|
log.Errorf("Error getting user members: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "getting user members")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,30 +192,31 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
u, err := s.DB.User(ctx, claims.UserID)
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Error getting user: %v", err)
|
log.Errorf("Error getting user: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "getting users")
|
||||||
}
|
}
|
||||||
|
|
||||||
fields, err := s.DB.UserFields(ctx, u.ID)
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Error getting user fields: %v", err)
|
log.Errorf("Error getting user fields: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "getting fields")
|
||||||
}
|
}
|
||||||
|
|
||||||
members, err := s.DB.UserMembers(ctx, u.ID, true)
|
members, err := s.DB.UserMembers(ctx, u.ID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Error getting user members: %v", err)
|
log.Errorf("Error getting user members: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "getting members")
|
||||||
}
|
}
|
||||||
|
|
||||||
flags, err := s.DB.UserFlags(ctx, u.ID)
|
flags, err := s.DB.UserFlags(ctx, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("getting user flags: %v", err)
|
log.Errorf("getting user flags: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "getting flags")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, GetMeResponse{
|
render.JSON(w, r, GetMeResponse{
|
||||||
GetUserResponse: dbUserToResponse(u, fields, members, flags),
|
GetUserResponse: dbUserToResponse(u, fields, members, flags),
|
||||||
CreatedAt: u.ID.Time(),
|
CreatedAt: u.ID.Time(),
|
||||||
|
Timezone: u.Timezone,
|
||||||
MaxInvites: u.MaxInvites,
|
MaxInvites: u.MaxInvites,
|
||||||
IsAdmin: u.IsAdmin,
|
IsAdmin: u.IsAdmin,
|
||||||
ListPrivate: u.ListPrivate,
|
ListPrivate: u.ListPrivate,
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -25,6 +26,7 @@ type PatchUserRequest struct {
|
||||||
Pronouns *[]db.PronounEntry `json:"pronouns"`
|
Pronouns *[]db.PronounEntry `json:"pronouns"`
|
||||||
Fields *[]db.Field `json:"fields"`
|
Fields *[]db.Field `json:"fields"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
|
Timezone *string `json:"timezone"`
|
||||||
ListPrivate *bool `json:"list_private"`
|
ListPrivate *bool `json:"list_private"`
|
||||||
CustomPreferences *db.CustomPreferences `json:"custom_preferences"`
|
CustomPreferences *db.CustomPreferences `json:"custom_preferences"`
|
||||||
Flags *[]xid.ID `json:"flags"`
|
Flags *[]xid.ID `json:"flags"`
|
||||||
|
@ -91,6 +93,19 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validate timezone
|
||||||
|
if req.Timezone != nil {
|
||||||
|
if *req.Timezone != "" {
|
||||||
|
_, err := time.LoadLocation(*req.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("%q is not a valid timezone", *req.Timezone),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// validate links
|
// validate links
|
||||||
if req.Links != nil {
|
if req.Links != nil {
|
||||||
if len(*req.Links) > db.MaxUserLinksLength {
|
if len(*req.Links) > db.MaxUserLinksLength {
|
||||||
|
@ -181,13 +196,13 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Errorf("converting user avatar: %v", err)
|
log.Errorf("converting user avatar: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "converting avatar")
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg)
|
hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("uploading user avatar: %v", err)
|
log.Errorf("uploading user avatar: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "uploading avatar")
|
||||||
}
|
}
|
||||||
avatarHash = &hash
|
avatarHash = &hash
|
||||||
|
|
||||||
|
@ -205,9 +220,14 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
tx, err := s.DB.Begin(ctx)
|
tx, err := s.DB.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("creating transaction: %v", err)
|
log.Errorf("creating transaction: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "creating transaction")
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
err := tx.Rollback(ctx)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||||
|
log.Error("rolling back transaction:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// update username
|
// update username
|
||||||
if req.Username != nil && *req.Username != u.Username {
|
if req.Username != nil && *req.Username != u.Username {
|
||||||
|
@ -218,16 +238,18 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrUsernameTaken}
|
return server.APIError{Code: server.ErrUsernameTaken}
|
||||||
case db.ErrInvalidUsername:
|
case db.ErrInvalidUsername:
|
||||||
return server.APIError{Code: server.ErrInvalidUsername}
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
case db.ErrBannedUsername:
|
||||||
|
return server.APIError{Code: server.ErrInvalidUsername, Details: "That username cannot be used."}
|
||||||
default:
|
default:
|
||||||
return errors.Wrap(err, "updating username")
|
return errors.Wrap(err, "updating username")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.CustomPreferences)
|
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.Timezone, req.CustomPreferences)
|
||||||
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
|
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
|
||||||
log.Errorf("updating user: %v", err)
|
log.Errorf("updating user: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "updating user")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Names != nil || req.Pronouns != nil {
|
if req.Names != nil || req.Pronouns != nil {
|
||||||
|
@ -244,7 +266,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns)
|
err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting names for member %v: %v", claims.UserID, err)
|
log.Errorf("setting names for member %v: %v", claims.UserID, err)
|
||||||
return err
|
return errors.Wrap(err, "setting names/pronouns")
|
||||||
}
|
}
|
||||||
u.Names = names
|
u.Names = names
|
||||||
u.Pronouns = pronouns
|
u.Pronouns = pronouns
|
||||||
|
@ -255,14 +277,14 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields)
|
err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting fields for user %v: %v", claims.UserID, err)
|
log.Errorf("setting fields for user %v: %v", claims.UserID, err)
|
||||||
return err
|
return errors.Wrap(err, "setting fields")
|
||||||
}
|
}
|
||||||
fields = *req.Fields
|
fields = *req.Fields
|
||||||
} else {
|
} else {
|
||||||
fields, err = s.DB.UserFields(ctx, claims.UserID)
|
fields, err = s.DB.UserFields(ctx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("getting fields for user %v: %v", claims.UserID, err)
|
log.Errorf("getting fields for user %v: %v", claims.UserID, err)
|
||||||
return err
|
return errors.Wrap(err, "getting fields")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -275,7 +297,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Errorf("updating flags for user %v: %v", claims.UserID, err)
|
log.Errorf("updating flags for user %v: %v", claims.UserID, err)
|
||||||
return err
|
return errors.Wrap(err, "updating flags")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -283,13 +305,13 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
|
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
|
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
|
||||||
return err
|
return errors.Wrap(err, "updating last active time")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit(ctx)
|
err = tx.Commit(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("committing transaction: %v", err)
|
log.Errorf("committing transaction: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "committing transaction")
|
||||||
}
|
}
|
||||||
|
|
||||||
// get fedi instance name if the user has a linked fedi account
|
// get fedi instance name if the user has a linked fedi account
|
||||||
|
@ -305,12 +327,14 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
flags, err := s.DB.UserFlags(ctx, u.ID)
|
flags, err := s.DB.UserFlags(ctx, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("getting user flags: %v", err)
|
log.Errorf("getting user flags: %v", err)
|
||||||
return err
|
return errors.Wrap(err, "getting flags")
|
||||||
}
|
}
|
||||||
|
|
||||||
// echo the updated user back on success
|
// echo the updated user back on success
|
||||||
render.JSON(w, r, GetMeResponse{
|
render.JSON(w, r, GetMeResponse{
|
||||||
GetUserResponse: dbUserToResponse(u, fields, nil, flags),
|
GetUserResponse: dbUserToResponse(u, fields, nil, flags),
|
||||||
|
CreatedAt: u.ID.Time(),
|
||||||
|
Timezone: u.Timezone,
|
||||||
MaxInvites: u.MaxInvites,
|
MaxInvites: u.MaxInvites,
|
||||||
IsAdmin: u.IsAdmin,
|
IsAdmin: u.IsAdmin,
|
||||||
ListPrivate: u.ListPrivate,
|
ListPrivate: u.ListPrivate,
|
22
backend/routes/v2/user/get_settings.go
Normal file
22
backend/routes/v2/user/get_settings.go
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
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
|
||||||
|
}
|
49
backend/routes/v2/user/patch_settings.go
Normal file
49
backend/routes/v2/user/patch_settings.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
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
|
||||||
|
}
|
23
backend/routes/v2/user/routes.go
Normal file
23
backend/routes/v2/user/routes.go
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
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,10 +1,14 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -12,6 +16,11 @@ import (
|
||||||
// The inner HandlerFunc additionally returns an error.
|
// The inner HandlerFunc additionally returns an error.
|
||||||
func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
|
func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hub := sentry.GetHubFromContext(r.Context())
|
||||||
|
if hub == nil {
|
||||||
|
hub = sentry.CurrentHub().Clone()
|
||||||
|
}
|
||||||
|
|
||||||
err := hn(w, r)
|
err := hn(w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// if the function returned an API error, just render that verbatim
|
// if the function returned an API error, just render that verbatim
|
||||||
|
@ -24,10 +33,20 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise, we log the error and return an internal server error message
|
rctx := chi.RouteContext(r.Context())
|
||||||
log.Errorf("error in http handler: %v", err)
|
hub.ConfigureScope(func(scope *sentry.Scope) {
|
||||||
|
scope.SetTag("method", rctx.RouteMethod)
|
||||||
|
scope.SetTag("path", rctx.RoutePattern())
|
||||||
|
})
|
||||||
|
|
||||||
apiErr := APIError{Code: ErrInternalServerError}
|
var eventID *sentry.EventID = nil
|
||||||
|
if isExpectedError(err) {
|
||||||
|
log.Infof("expected error in handler for %v %v, ignoring", rctx.RouteMethod, rctx.RoutePattern())
|
||||||
|
} else {
|
||||||
|
log.Errorf("error in handler for %v %v: %v", rctx.RouteMethod, rctx.RoutePattern(), err)
|
||||||
|
eventID = hub.CaptureException(err)
|
||||||
|
}
|
||||||
|
apiErr := APIError{ID: eventID, Code: ErrInternalServerError}
|
||||||
apiErr.prepare()
|
apiErr.prepare()
|
||||||
|
|
||||||
render.Status(r, apiErr.Status)
|
render.Status(r, apiErr.Status)
|
||||||
|
@ -36,12 +55,17 @@ 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"`
|
||||||
Message string `json:"message,omitempty"`
|
ID *sentry.EventID `json:"id,omitempty"`
|
||||||
Details string `json:"details,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
|
||||||
RatelimitReset *int `json:"ratelimit_reset,omitempty"`
|
RatelimitReset *int `json:"ratelimit_reset,omitempty"`
|
||||||
|
|
||||||
|
|
89
backend/server/sentry.go
Normal file
89
backend/server/sentry.go
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) sentry(handler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
hub := sentry.GetHubFromContext(ctx)
|
||||||
|
if hub == nil {
|
||||||
|
hub = sentry.CurrentHub().Clone()
|
||||||
|
ctx = sentry.SetHubOnContext(ctx, hub)
|
||||||
|
}
|
||||||
|
|
||||||
|
options := []sentry.SpanOption{
|
||||||
|
sentry.WithOpName("http.server"),
|
||||||
|
sentry.ContinueFromRequest(r),
|
||||||
|
sentry.WithTransactionSource(sentry.SourceURL),
|
||||||
|
}
|
||||||
|
// We don't mind getting an existing transaction back so we don't need to
|
||||||
|
// check if it is.
|
||||||
|
transaction := sentry.StartTransaction(ctx,
|
||||||
|
fmt.Sprintf("%s %s", r.Method, r.URL.Path),
|
||||||
|
options...,
|
||||||
|
)
|
||||||
|
defer transaction.Finish()
|
||||||
|
r = r.WithContext(transaction.Context())
|
||||||
|
hub.Scope().SetRequest(r)
|
||||||
|
defer recoverWithSentry(hub, r)
|
||||||
|
handler.ServeHTTP(ww, r)
|
||||||
|
|
||||||
|
transaction.Status = httpStatusToSentryStatus(ww.Status())
|
||||||
|
rctx := chi.RouteContext(r.Context())
|
||||||
|
transaction.Name = rctx.RouteMethod + " " + rctx.RoutePattern()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func recoverWithSentry(hub *sentry.Hub, r *http.Request) {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
hub.RecoverWithContext(
|
||||||
|
context.WithValue(r.Context(), sentry.RequestContextKey, r),
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpStatusToSentryStatus(status int) sentry.SpanStatus {
|
||||||
|
// c.f. https://develop.sentry.dev/sdk/event-payloads/span/
|
||||||
|
|
||||||
|
if status >= 200 && status < 400 {
|
||||||
|
return sentry.SpanStatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case 499:
|
||||||
|
return sentry.SpanStatusCanceled
|
||||||
|
case 500:
|
||||||
|
return sentry.SpanStatusInternalError
|
||||||
|
case 400:
|
||||||
|
return sentry.SpanStatusInvalidArgument
|
||||||
|
case 504:
|
||||||
|
return sentry.SpanStatusDeadlineExceeded
|
||||||
|
case 404:
|
||||||
|
return sentry.SpanStatusNotFound
|
||||||
|
case 409:
|
||||||
|
return sentry.SpanStatusAlreadyExists
|
||||||
|
case 403:
|
||||||
|
return sentry.SpanStatusPermissionDenied
|
||||||
|
case 429:
|
||||||
|
return sentry.SpanStatusResourceExhausted
|
||||||
|
case 501:
|
||||||
|
return sentry.SpanStatusUnimplemented
|
||||||
|
case 503:
|
||||||
|
return sentry.SpanStatusUnavailable
|
||||||
|
case 401:
|
||||||
|
return sentry.SpanStatusUnauthenticated
|
||||||
|
default:
|
||||||
|
return sentry.SpanStatusUnknown
|
||||||
|
}
|
||||||
|
}
|
|
@ -50,6 +50,9 @@ func New() (*Server, error) {
|
||||||
s.Router.Use(middleware.Logger)
|
s.Router.Use(middleware.Logger)
|
||||||
}
|
}
|
||||||
s.Router.Use(middleware.Recoverer)
|
s.Router.Use(middleware.Recoverer)
|
||||||
|
// add Sentry tracing handler
|
||||||
|
s.Router.Use(s.sentry)
|
||||||
|
|
||||||
// add CORS
|
// add CORS
|
||||||
s.Router.Use(cors.Handler(cors.Options{
|
s.Router.Use(cors.Handler(cors.Options{
|
||||||
AllowedOrigins: []string{"https://*", "http://*"},
|
AllowedOrigins: []string{"https://*", "http://*"},
|
||||||
|
@ -97,23 +100,23 @@ func New() (*Server, error) {
|
||||||
|
|
||||||
// set scopes
|
// set scopes
|
||||||
// users
|
// users
|
||||||
rateLimiter.Scope("GET", "/users/*", 60)
|
_ = rateLimiter.Scope("GET", "/users/*", 60)
|
||||||
rateLimiter.Scope("PATCH", "/users/@me", 10)
|
_ = rateLimiter.Scope("PATCH", "/users/@me", 10)
|
||||||
|
|
||||||
// members
|
// members
|
||||||
rateLimiter.Scope("GET", "/users/*/members", 60)
|
_ = rateLimiter.Scope("GET", "/users/*/members", 60)
|
||||||
rateLimiter.Scope("GET", "/users/*/members/*", 60)
|
_ = rateLimiter.Scope("GET", "/users/*/members/*", 60)
|
||||||
|
|
||||||
rateLimiter.Scope("POST", "/members", 10)
|
_ = rateLimiter.Scope("POST", "/members", 10)
|
||||||
rateLimiter.Scope("GET", "/members/*", 60)
|
_ = rateLimiter.Scope("GET", "/members/*", 60)
|
||||||
rateLimiter.Scope("PATCH", "/members/*", 20)
|
_ = rateLimiter.Scope("PATCH", "/members/*", 20)
|
||||||
rateLimiter.Scope("DELETE", "/members/*", 5)
|
_ = rateLimiter.Scope("DELETE", "/members/*", 5)
|
||||||
|
|
||||||
// auth
|
// auth
|
||||||
rateLimiter.Scope("*", "/auth/*", 20)
|
_ = rateLimiter.Scope("*", "/auth/*", 20)
|
||||||
rateLimiter.Scope("*", "/auth/tokens", 10)
|
_ = rateLimiter.Scope("*", "/auth/tokens", 10)
|
||||||
rateLimiter.Scope("*", "/auth/invites", 10)
|
_ = rateLimiter.Scope("*", "/auth/invites", 10)
|
||||||
rateLimiter.Scope("POST", "/auth/discord/*", 10)
|
_ = rateLimiter.Scope("POST", "/auth/discord/*", 10)
|
||||||
|
|
||||||
s.Router.Use(rateLimiter.Handler())
|
s.Router.Use(rateLimiter.Handler())
|
||||||
|
|
||||||
|
|
1
docs/.vitepress/.gitignore
vendored
Normal file
1
docs/.vitepress/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
cache/
|
41
docs/.vitepress/config.mts
Normal file
41
docs/.vitepress/config.mts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { defineConfig } from "vitepress";
|
||||||
|
|
||||||
|
// https://vitepress.dev/reference/site-config
|
||||||
|
export default defineConfig({
|
||||||
|
title: "pronouns.cc documentation",
|
||||||
|
description: "pronouns.cc documentation",
|
||||||
|
markdown: {
|
||||||
|
anchor: { level: [2, 3] },
|
||||||
|
},
|
||||||
|
themeConfig: {
|
||||||
|
// https://vitepress.dev/reference/default-theme-config
|
||||||
|
siteTitle: "pronouns.cc",
|
||||||
|
logo: "/logo.svg",
|
||||||
|
nav: [
|
||||||
|
{ text: "Home", link: "/" },
|
||||||
|
{ text: "Back to pronouns.cc", link: "https://pronouns.cc/" },
|
||||||
|
],
|
||||||
|
outline: {
|
||||||
|
level: [2, 3],
|
||||||
|
},
|
||||||
|
sidebar: [
|
||||||
|
{
|
||||||
|
text: "API",
|
||||||
|
items: [
|
||||||
|
{ text: "API reference", link: "/api/" },
|
||||||
|
{ text: "Rate limits", link: "/api/ratelimits" },
|
||||||
|
{ text: "Error messages", link: "/api/errors" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Endpoints",
|
||||||
|
items: [
|
||||||
|
{ text: "Object reference", link: "/api/endpoints/" },
|
||||||
|
{ text: "Users", link: "/api/endpoints/users" },
|
||||||
|
{ text: "Members", link: "/api/endpoints/members" },
|
||||||
|
{ text: "Other", link: "/api/endpoints/other" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
4
docs/.vitepress/theme/custom.css
Normal file
4
docs/.vitepress/theme/custom.css
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
:root {
|
||||||
|
--vp-font-family-base: "FiraGO", sans-serif;
|
||||||
|
--vp-font-family-mono: "Fira Mono", monospace;
|
||||||
|
}
|
9
docs/.vitepress/theme/index.mts
Normal file
9
docs/.vitepress/theme/index.mts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import DefaultTheme from 'vitepress/theme-without-fonts'
|
||||||
|
import "@fontsource/firago/400.css";
|
||||||
|
import "@fontsource/firago/400-italic.css";
|
||||||
|
import "@fontsource/firago/700.css";
|
||||||
|
import "@fontsource/firago/700-italic.css";
|
||||||
|
import "@fontsource/fira-mono";
|
||||||
|
import "./custom.css";
|
||||||
|
|
||||||
|
export default DefaultTheme
|
54
docs/api/endpoints/index.md
Normal file
54
docs/api/endpoints/index.md
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
# Object reference
|
||||||
|
|
||||||
|
These are some of the objects shared by multiple types of endpoints.
|
||||||
|
For other objects, such as [users](./users) or [members](./members), check their respective pages.
|
||||||
|
|
||||||
|
## Field
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------- | ------------------------------- | --------------------------- |
|
||||||
|
| name | string | the field's name or heading |
|
||||||
|
| entries | [field_entry](./#field-entry)[] | the field's entries |
|
||||||
|
|
||||||
|
## Field entry
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------ | ------ | -------------------------------- |
|
||||||
|
| value | string | this entry's value or key |
|
||||||
|
| status | string | this entry's [status](./#status) |
|
||||||
|
|
||||||
|
## Pronoun entry
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------ | ------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| pronouns | string | this entry's raw pronouns. This can be any user-inputted value and does not have to be a complete set |
|
||||||
|
| display_text | string? | the text shown in the pronoun list, if `pronouns` is a valid 5-member set |
|
||||||
|
| status | string | this entry's [status](./#status) |
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
A name, pronoun, or field entry's **status** is how the user or member feels about that entry.
|
||||||
|
This can be any of `favourite`, `okay`, `jokingly`, `friends_only`, `avoid`,
|
||||||
|
as well as the UUID of any [custom preferences](./#custom-preference) the user has set.
|
||||||
|
|
||||||
|
## Custom preference
|
||||||
|
|
||||||
|
A user can set custom word preferences, which can have custom icons and tooltips. These are identified by a UUID.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --------- | ------ | ---------------------------------------------------------------------------------------------------- |
|
||||||
|
| icon | string | the [Bootstrap icon](https://icons.getbootstrap.com/) associated with this preference |
|
||||||
|
| tooltip | string | the description shown in the tooltip on hover or tap |
|
||||||
|
| size | string | the size at which any entry with this preference will be shown, can be `large`, `normal`, or `small` |
|
||||||
|
| muted | bool | whether the preference is shown in a muted grey colour |
|
||||||
|
| favourite | bool | whether the preference is treated the same as `favourite` when building embeds |
|
||||||
|
|
||||||
|
## Pride flag
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ----------- | --------- | ------------------------------------- |
|
||||||
|
| 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) |
|
||||||
|
| name | string | the flag's name |
|
||||||
|
| description | string? | the flag's description or alt text |
|
125
docs/api/endpoints/members.md
Normal file
125
docs/api/endpoints/members.md
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
# Member endpoints
|
||||||
|
|
||||||
|
## Member object
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------ | ---------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
|
| 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 |
|
||||||
|
| name | string | the member's name |
|
||||||
|
| display_name | string? | the member's display name or nickname |
|
||||||
|
| bio | string? | the member's description |
|
||||||
|
| avatar | string? | the member's [avatar hash](/api/#images) |
|
||||||
|
| links | string[] | the member's profile links |
|
||||||
|
| names | [field_entry](./#field-entry)[] | the member's preferred names |
|
||||||
|
| pronouns | [pronoun_entry](./#pronoun-entry)[] | the member's preferred pronouns |
|
||||||
|
| fields | ?[field](./#field)[] | the member's term fields. Not returned in member list endpoints. |
|
||||||
|
| flags | [flag](./#pride-flag)[] | the member's pride flags |
|
||||||
|
| user | partial [user](./members#partial-user-object) object | the user associated with this member |
|
||||||
|
| unlisted | ?bool | _only returned for your own members_, whether the member is shown in member lists |
|
||||||
|
|
||||||
|
## Partial user object
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------------ | ---------------------------------------------------- | -------------------------------------- |
|
||||||
|
| id | string | the user's unique ID |
|
||||||
|
| id_new | snowflake | the user's unique snowflake ID |
|
||||||
|
| name | string | the user's username |
|
||||||
|
| display_name | string? | the user's display name or nickname |
|
||||||
|
| avatar | string? | the user's [avatar hash](/api/#images) |
|
||||||
|
| custom_preferences | map\[uuid\][custom_preference](./#custom-preference) | the user's custom preferences |
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Get member
|
||||||
|
|
||||||
|
#### `GET /members/{member.id}`
|
||||||
|
|
||||||
|
Gets a member by their ID. Returns a [member](./members#member-object) object.
|
||||||
|
If authenticated and the authenticated user is the owner of the requested member,
|
||||||
|
also returns the `unlisted` field.
|
||||||
|
|
||||||
|
### Get user member
|
||||||
|
|
||||||
|
#### `GET /users/{user.id}/members/{member.id} | GET /users/{user.name}/members/{member.name}`
|
||||||
|
|
||||||
|
Gets a member by their ID or name. Returns a [member](./members#member-object) object.
|
||||||
|
If authenticated and the authenticated user is the owner of the requested member,
|
||||||
|
also returns the `unlisted` field.
|
||||||
|
|
||||||
|
### Get user members
|
||||||
|
|
||||||
|
#### `GET /users/{user.id}/members | GET /users/{user.name}/members`
|
||||||
|
|
||||||
|
Get a user's members. Returns an array of [member](./members#member-object) objects.
|
||||||
|
|
||||||
|
### Get current user member
|
||||||
|
|
||||||
|
#### `GET /users/@me/members/{member.id} | GET /users/@me/members/{member.name}`
|
||||||
|
|
||||||
|
**Requires authentication.** Get one of the currently authenticated user's members by ID or name.
|
||||||
|
Returns a [member](./members#member-object) object.
|
||||||
|
|
||||||
|
### Get current user members
|
||||||
|
|
||||||
|
#### `GET /users/@me/members`
|
||||||
|
|
||||||
|
**Requires authentication.** Get the currently authenticated user's members.
|
||||||
|
Returns an array of [member](./members#member-object) objects.
|
||||||
|
|
||||||
|
### Create member
|
||||||
|
|
||||||
|
#### `POST /members`
|
||||||
|
|
||||||
|
**Requires authentication**. Creates a new member.
|
||||||
|
Returns the newly created [member](./members#member-object) on success.
|
||||||
|
|
||||||
|
#### Request body parameters
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------ | --------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
|
| name | string | the new member's name. Must be unique per user, and be between 1 and 100 characters. **Required** |
|
||||||
|
| display_name | string? | the new member's display name. Must be between 1 and 100 characters |
|
||||||
|
| bio | string? | the new member's bio. Must be between 1 and 1000 characters |
|
||||||
|
| avatar | string | the new member's avatar. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format |
|
||||||
|
| links | string[] | the new member's profile links. Maximum 25 links, and links must be between 1 and 256 characters |
|
||||||
|
| names | field_entry[] | the new member's preferred names |
|
||||||
|
| pronouns | pronoun_entry[] | the new member's preferred pronouns |
|
||||||
|
| fields | field[] | the new member's profile fields |
|
||||||
|
|
||||||
|
### Update member
|
||||||
|
|
||||||
|
#### `PATCH /members/{member.id}`
|
||||||
|
|
||||||
|
**Requires authentication.** Updates the given member.
|
||||||
|
Returns the updated [member](./members#member-object) on success.
|
||||||
|
|
||||||
|
#### Request body parameters
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------ | --------------- | ------------------------------------------------------------------------------------------------------ |
|
||||||
|
| 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 |
|
||||||
|
| bio | string | the member's new bio. Must be between 1 and 1000 characters |
|
||||||
|
| links | string[] | the member's new profile links. Maximum 25 links, and links must be between 1 and 256 characters |
|
||||||
|
| names | field_entry[] | the member's new preferred names |
|
||||||
|
| pronouns | pronoun_entry[] | the member's new preferred pronouns |
|
||||||
|
| 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. |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
### Delete member
|
||||||
|
|
||||||
|
#### `DELETE /members/{member.id}`
|
||||||
|
|
||||||
|
**Requires authentication.** Deletes the given member. Returns `204 No Content` on success.
|
||||||
|
|
||||||
|
### Reroll short ID
|
||||||
|
|
||||||
|
#### `GET /members/{member.id}/reroll`
|
||||||
|
|
||||||
|
**Requires authentication.** Rerolls the member's short ID.
|
||||||
|
Returns the updated [member](./members#member-object) on success.
|
||||||
|
If the user has already rerolled a short ID in the past hour, returns `403 Forbidden`.
|
46
docs/api/endpoints/other.md
Normal file
46
docs/api/endpoints/other.md
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
# Other endpoints
|
||||||
|
|
||||||
|
There are some endpoints that are neither user or member related:
|
||||||
|
|
||||||
|
### Get statistics
|
||||||
|
|
||||||
|
#### `GET /meta`
|
||||||
|
|
||||||
|
Get aggregate statistics for pronouns.cc.
|
||||||
|
Note: a user is considered active if they have updated their profile, created a member, deleted a member,
|
||||||
|
or updated a member's profile in the given time period.
|
||||||
|
|
||||||
|
#### Response body
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| -------------- | ----------------- | ------------------------------------------------------------------------- |
|
||||||
|
| git_repository | string | link to the project's Git repository |
|
||||||
|
| git_commit | string | the commit the backend is built from |
|
||||||
|
| users | user count object | the total number of users |
|
||||||
|
| members | int | the total number of non-hidden members |
|
||||||
|
| require_invite | bool | whether invites are required to sign up. _Always `false` for pronouns.cc_ |
|
||||||
|
|
||||||
|
#### User count object
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------ | ---- | ------------------------------------------- |
|
||||||
|
| total | int | total number of users |
|
||||||
|
| active_month | int | number of users active in the last month |
|
||||||
|
| active_week | int | number of users active in the last week |
|
||||||
|
| active_day | int | number of users active in the last 24 hours |
|
||||||
|
|
||||||
|
### Get warnings
|
||||||
|
|
||||||
|
#### `GET /auth/warnings`
|
||||||
|
|
||||||
|
**Requires authentication.** Returns an array of warnings the currently authenticated user has.
|
||||||
|
Add `?all=true` query parameter to return all warnings, not just unread ones.
|
||||||
|
|
||||||
|
#### Response body
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------- | -------- | ---------------------------------------------- |
|
||||||
|
| id | int | the warning ID |
|
||||||
|
| reason | string | the reason for the warning |
|
||||||
|
| created_at | datetime | when the warning was created |
|
||||||
|
| read | bool | whether the warning has been read/acknowledged |
|
145
docs/api/endpoints/users.md
Normal file
145
docs/api/endpoints/users.md
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
# User endpoints
|
||||||
|
|
||||||
|
## User object
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------------ | ---------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||||
|
| 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 |
|
||||||
|
| name | string | the user's username |
|
||||||
|
| display_name | string? | the user's display name or nickname |
|
||||||
|
| bio | string? | the user's description or bio |
|
||||||
|
| member_title | string? | the heading used for the user's member list. If null, defaults to "Members" |
|
||||||
|
| avatar | string? | the user's [avatar hash](/api/#images) |
|
||||||
|
| links | string[] | the user's profile links |
|
||||||
|
| names | [field_entry](./#field-entry)[] | the user's preferred names |
|
||||||
|
| pronouns | [pronoun_entry](./#pronoun-entry)[] | the user's preferred pronouns |
|
||||||
|
| fields | [field](./#field)[] | the user's term fields |
|
||||||
|
| flags | [flag](./#pride-flag)[] | the user's pride flags |
|
||||||
|
| members | [partial](./users#partial-member-object) member[] | the user's non-hidden members |
|
||||||
|
| badges | int | the user's badges, represented as a bitmask field |
|
||||||
|
| utc_offset | int? | the user's current offset from UTC, in seconds |
|
||||||
|
| custom_preferences | map\[uuid\][custom_preference](./#custom-preference) | the user's custom preferences |
|
||||||
|
|
||||||
|
### Additional fields for the currently authenticated user {#additional-user-fields}
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------------ | -------- | ------------------------------------------------ |
|
||||||
|
| created_at | datetime | the user's creation date and time |
|
||||||
|
| timezone | string? | the user's timezone in IANA timezone format |
|
||||||
|
| is_admin | bool | whether or not the user is an administrator |
|
||||||
|
| list_private | bool | whether or not the user's member list is private |
|
||||||
|
| last_sid_reroll | datetime | the last time the user rerolled a short ID |
|
||||||
|
| discord | string? | the user's Discord ID |
|
||||||
|
| discord_username | string? | the user's Discord username |
|
||||||
|
| tumblr | string? | the user's Tumblr ID |
|
||||||
|
| tumblr_username | string? | the user's Tumblr username |
|
||||||
|
| google | string? | the user's Google ID |
|
||||||
|
| google_username | string? | the user's Google username |
|
||||||
|
| fediverse | string? | the user's fediverse user ID |
|
||||||
|
| fediverse_username | string? | the user's fediverse username, without instance |
|
||||||
|
| fediverse_instance | string? | the user's fediverse instance |
|
||||||
|
|
||||||
|
## Partial member object
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------ | ----------------------------------- | ---------------------------------------- |
|
||||||
|
| 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 |
|
||||||
|
| name | string | the member's name |
|
||||||
|
| display_name | string? | the member's display name or nickname |
|
||||||
|
| bio | string? | the member's description |
|
||||||
|
| avatar | string? | the member's [avatar hash](/api/#images) |
|
||||||
|
| links | string[] | the member's profile links |
|
||||||
|
| names | [field_entry](./#field-entry)[] | the member's preferred names |
|
||||||
|
| pronouns | [pronoun_entry](./#pronoun-entry)[] | the member's preferred pronouns |
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Get user
|
||||||
|
|
||||||
|
#### `GET /users/{user.id} | GET /users/{user.name}`
|
||||||
|
|
||||||
|
Gets a user by their ID or username. Returns a [user](./users#user-object) object.
|
||||||
|
If authenticated and the authenticated user is the requested user, also returns the [additional user fields](./users#additional-user-fields).
|
||||||
|
|
||||||
|
### Get current user
|
||||||
|
|
||||||
|
#### `GET /users/@me`
|
||||||
|
|
||||||
|
**Requires authentication.** Gets the currently authenticated [user](./users#user-object),
|
||||||
|
with all [additional user fields](./users#additional-user-fields).
|
||||||
|
|
||||||
|
### Update current user
|
||||||
|
|
||||||
|
#### `PATCH /users/@me`
|
||||||
|
|
||||||
|
**Requires authentication.** Updates the currently authenticated user.
|
||||||
|
Returns the updated [user](./users#user-object) object on success.
|
||||||
|
|
||||||
|
#### Request body parameters
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| name | string | the user's new name. Must be between 2 and 40 characters and only consist of ASCII letters, `_`, `.`, and `-` |
|
||||||
|
| display_name | string | the user's new display name. Must be between 1 and 100 characters |
|
||||||
|
| bio | string | the user's new bio. Must be between 1 and 1000 characters |
|
||||||
|
| member_title | string | the user's new member title. Must be between 1 and 150 characters |
|
||||||
|
| links | string[] | the user's new profile links. Maximum 25 links, and links must be between 1 and 256 characters |
|
||||||
|
| names | field_entry[] | the user's new preferred names |
|
||||||
|
| pronouns | pronoun_entry[] | the user's new preferred pronouns |
|
||||||
|
| 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. |
|
||||||
|
| 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 |
|
||||||
|
| list_private | bool | whether or not the user's member list should be hidden |
|
||||||
|
| custom_preferences | _custom preferences_ | the user's new custom preferences |
|
||||||
|
|
||||||
|
### Get pride flags
|
||||||
|
|
||||||
|
#### `GET /users/@me/flags`
|
||||||
|
|
||||||
|
**Requires authentication.** Returns an array of the currently authenticated user's [pride flags](./#pride-flag).
|
||||||
|
|
||||||
|
### Create pride flag
|
||||||
|
|
||||||
|
#### `POST /users/@me/flags`
|
||||||
|
|
||||||
|
**Requires authentication.** Creates a new pride flag. Returns a [pride flag](./#pride-flag) object on success.
|
||||||
|
|
||||||
|
#### Request body parameters
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ----------- | ------ | -------------------------------------------------------------------------------------------------------- |
|
||||||
|
| flag | string | the flag image. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format. **Required** |
|
||||||
|
| name | string | the flag name. Must be between 1 and 100 characters. **Required** |
|
||||||
|
| description | string | the flag description or alt text. |
|
||||||
|
|
||||||
|
### Edit pride flag
|
||||||
|
|
||||||
|
#### `PATCH /users/@me/flags/{flag.id}`
|
||||||
|
|
||||||
|
**Requires authentication.** Edits an existing pride flag.
|
||||||
|
Returns the updated [pride flag](./#pride-flag) object on success.
|
||||||
|
|
||||||
|
#### Request body parameters
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ----------- | ------ | ---------------------------------------------------------------- |
|
||||||
|
| name | string | the flag's new name. Must be between 1 and 100 characters |
|
||||||
|
| description | string | the flag's new description. Must be between 1 and 500 characters |
|
||||||
|
|
||||||
|
### Delete pride flag
|
||||||
|
|
||||||
|
#### `DELETE /users/@me/flags/{flag.id}`
|
||||||
|
|
||||||
|
**Requires authentication.** Deletes an existing pride flag. Returns `204 No Content` on success.
|
||||||
|
|
||||||
|
### Reroll short ID
|
||||||
|
|
||||||
|
#### `GET /users/@me/reroll`
|
||||||
|
|
||||||
|
**Requires authentication.** Rerolls the user's short ID. Returns the updated [user](./users#user-object) on success.
|
||||||
|
If the user has already rerolled a short ID in the past hour, returns `403 Forbidden`.
|
34
docs/api/errors.md
Normal file
34
docs/api/errors.md
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
# Error messages
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| --------------- | ------- | ------------------------------------------------------------------- |
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
### Error codes
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| ---- | ----------------------------------------------------------------------------------- |
|
||||||
|
| 400 | One or more fields in your requests was invalid, or some required field is missing. |
|
||||||
|
| 403 | You are not authorized to use this endpoint. |
|
||||||
|
| 404 | The endpoint was not found. |
|
||||||
|
| 405 | The method you are trying to use is not suported for this endpoint. |
|
||||||
|
| 429 | You have made too many requests in the last minute. |
|
||||||
|
| 500 | An internal server error occurred. |
|
||||||
|
| 1006 | That username is invalid. |
|
||||||
|
| 1007 | That username is already taken. |
|
||||||
|
| 2001 | User not found. |
|
||||||
|
| 2002 | This user's member list is private. |
|
||||||
|
| 2003 | You have reached the maximum number of pride flags. |
|
||||||
|
| 2004 | You are trying to reroll short IDs too quickly. |
|
||||||
|
| 3001 | Member not found. |
|
||||||
|
| 3002 | You have reached the maximum number of members. |
|
||||||
|
| 3003 | That member name is already in use. |
|
||||||
|
| 3004 | You can only edit your own members. |
|
||||||
|
| 4001 | Your request is too big (maximum 2 megabytes) |
|
||||||
|
| 4002 | This endpoint is unavailable to your account or the current token. |
|
95
docs/api/index.md
Normal file
95
docs/api/index.md
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
# API reference
|
||||||
|
|
||||||
|
pronouns.cc has a HTTP REST API to query and edit profiles, available at `https://pronouns.cc/api`.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
The API is versioned, and versions must be explicitly specified for all endpoints.
|
||||||
|
The current, and only, available version is **1**.
|
||||||
|
The version is specified in the request path, like `https://pronouns.cc/api/v{version}`.
|
||||||
|
|
||||||
|
| Version | Status |
|
||||||
|
| ------- | ---------- |
|
||||||
|
| 1 | Default |
|
||||||
|
| 2 | _Upcoming_ |
|
||||||
|
|
||||||
|
The API version will be incremented for any breaking changes, including:
|
||||||
|
|
||||||
|
- Removing entire endpoints
|
||||||
|
- Removing fields from responses
|
||||||
|
- Changing the behaviour of fields (in some situations, see below)
|
||||||
|
|
||||||
|
However, the following types of changes are **not** considered breaking:
|
||||||
|
|
||||||
|
- Adding new endpoints
|
||||||
|
- Adding new fields to requests or responses (your JSON serializer/deserializer should ignore unknown fields)
|
||||||
|
- Forcing fields related to removed features to their default value
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Tokens can be created [here](https://pronouns.cc/settings/tokens).
|
||||||
|
Not all endpoints require authentication. For those that do, a token must be provided in the `Authorization` header.
|
||||||
|
The token _may_ be prefixed with `Bearer `, but this is not required.
|
||||||
|
|
||||||
|
::: info
|
||||||
|
You are allowed to use site tokens (those stored in your browser's local storage) to access endpoints not available to API tokens,
|
||||||
|
however, these endpoints are not available to API tokens *for a reason*:
|
||||||
|
site tokens can take destructive actions such as deleting your account.
|
||||||
|
Additionally, endpoints that are not available to API tokens may have breaking changes without a major version bump.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Request bodies
|
||||||
|
|
||||||
|
::: info
|
||||||
|
The current API version doesn't distinguish between omitted and `null` keys yet.
|
||||||
|
However, the next version of the API will use `null` to unset keys, so clients should not rely on this behaviour.
|
||||||
|
:::
|
||||||
|
|
||||||
|
Request bodies should be in JSON format.
|
||||||
|
For PATCH requests, **all keys are optional**. Omitted keys will not be updated,
|
||||||
|
and keys set to the zero value of their respective types (for strings: `""`, for numbers: `0`, for arrays: `[]`, etc.)
|
||||||
|
will be unset.
|
||||||
|
|
||||||
|
## Documentation formatting
|
||||||
|
|
||||||
|
The "type" column in tables is formatted as follows:
|
||||||
|
|
||||||
|
- The type used is the _Go_ type, not the _JSON_ type.
|
||||||
|
For example, the documentation will use `int` for integers and `float` for floats,
|
||||||
|
even though they are both represented with JSON numbers.
|
||||||
|
- A _leading_ `?` signifies that the field may be omitted.
|
||||||
|
- A _trailing_ `?` signifies that the field may be null.
|
||||||
|
|
||||||
|
## IDs
|
||||||
|
|
||||||
|
### Snowflake IDs
|
||||||
|
|
||||||
|
For [multiple reasons](https://codeberg.org/pronounscc/pronouns.cc/issues/89),
|
||||||
|
pronouns.cc is transitioning to using snowflakes for unique IDs. These will become the default in the next API version,
|
||||||
|
but are already returned as `id_new` in the relevant objects (users, members, and flags).
|
||||||
|
|
||||||
|
### xids
|
||||||
|
|
||||||
|
[xid](https://github.com/rs/xid) is the previous unique ID format. These are always serialized as strings.
|
||||||
|
Although xids have timestamp information embedded in them, this is non-trivial to extract.
|
||||||
|
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`.
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Images
|
||||||
|
|
||||||
|
The API does not return full URLs to images such as avatars and pride flags.
|
||||||
|
Instead, the URL must be constructed manually using the `avatar` or `hash` fields.
|
||||||
|
|
||||||
|
The default user and member avatar is served at `https://pronouns.cc/default/512.webp`.
|
||||||
|
All custom images are served on the base URL `https://cdn.pronouns.cc`, and are only available in WebP format.
|
||||||
|
|
||||||
|
| Type | Format |
|
||||||
|
| ------------- | ------------------------------------------- |
|
||||||
|
| User avatar | `/users/{user.id}/{user.avatar}.webp` |
|
||||||
|
| Member avatar | `/members/{member.id}/{member.avatar}.webp` |
|
||||||
|
| Pride flag | `/flags/{flag.hash}.webp` |
|
31
docs/api/ratelimits.md
Normal file
31
docs/api/ratelimits.md
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
# Rate limits
|
||||||
|
|
||||||
|
The API has rate limits, generally separated by groups of endpoints.
|
||||||
|
If you exceed a rate limit, the API will start to return 429 errors.
|
||||||
|
|
||||||
|
## Headers
|
||||||
|
|
||||||
|
- `X-RateLimit-Bucket`: the bucket the rate limit is for (listed below)
|
||||||
|
- `X-RateLimit-Limit`: the total number of requests you can make per minute
|
||||||
|
- `X-RateLimit-Remaining`: the number of requests remaining in the current timeframe
|
||||||
|
- `X-RateLimit-Reset`: the unix timestamp that the number of requests resets at
|
||||||
|
- `Retry-After`: only if you hit a rate limit, the number of seconds until you can make requests again
|
||||||
|
|
||||||
|
## Buckets
|
||||||
|
|
||||||
|
Note that only the most specific matching bucket is used for rate limits.
|
||||||
|
|
||||||
|
| Bucket | Rate limit per minute | Notes |
|
||||||
|
| ------------------------ | --------------------- | ----------------------------------------------------------- |
|
||||||
|
| / | 120 | Used as fallback if no other bucket exists for the endpoint |
|
||||||
|
| GET /users/\* | 60 | |
|
||||||
|
| GET /users/\*/members | 60 | |
|
||||||
|
| GET /users/\*/members/\* | 60 | |
|
||||||
|
| PATCH /users/@me | 10 | |
|
||||||
|
| POST /members | 10 | |
|
||||||
|
| GET /members/\* | 60 | |
|
||||||
|
| PATCH /members/\* | 20 | |
|
||||||
|
| DELETE /members/\* | 5 | |
|
||||||
|
| /auth/\* | 20 | |
|
||||||
|
| /auth/tokens | 10 | |
|
||||||
|
| /auth/invites | 10 | |
|
5
docs/index.md
Normal file
5
docs/index.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# pronouns.cc
|
||||||
|
|
||||||
|
pronouns.cc is a service where you can create a list of your preferred names, pronouns, and other terms, and share it with other people.
|
||||||
|
|
||||||
|
*Note: this documentation site is a work in progress, and currently only contains (partial) API documentation.*
|
14
docs/package.json
Normal file
14
docs/package.json
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"vitepress": "1.0.0-rc.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"docs:dev": "vitepress dev",
|
||||||
|
"docs:build": "vitepress build",
|
||||||
|
"docs:preview": "vitepress preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/fira-mono": "^5.0.8",
|
||||||
|
"@fontsource/firago": "^5.0.7"
|
||||||
|
}
|
||||||
|
}
|
876
docs/pnpm-lock.yaml
generated
Normal file
876
docs/pnpm-lock.yaml
generated
Normal file
|
@ -0,0 +1,876 @@
|
||||||
|
lockfileVersion: '6.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
'@fontsource/fira-mono':
|
||||||
|
specifier: ^5.0.8
|
||||||
|
version: 5.0.8
|
||||||
|
'@fontsource/firago':
|
||||||
|
specifier: ^5.0.7
|
||||||
|
version: 5.0.7
|
||||||
|
|
||||||
|
devDependencies:
|
||||||
|
vitepress:
|
||||||
|
specifier: 1.0.0-rc.4
|
||||||
|
version: 1.0.0-rc.4(@algolia/client-search@4.19.1)(search-insights@2.7.0)
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
/@algolia/autocomplete-core@1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)(search-insights@2.7.0):
|
||||||
|
resolution: {integrity: sha512-009HdfugtGCdC4JdXUbVJClA0q0zh24yyePn+KUGk3rP7j8FEe/m5Yo/z65gn6nP/cM39PxpzqKrL7A6fP6PPw==}
|
||||||
|
dependencies:
|
||||||
|
'@algolia/autocomplete-plugin-algolia-insights': 1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)(search-insights@2.7.0)
|
||||||
|
'@algolia/autocomplete-shared': 1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@algolia/client-search'
|
||||||
|
- algoliasearch
|
||||||
|
- search-insights
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/autocomplete-plugin-algolia-insights@1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)(search-insights@2.7.0):
|
||||||
|
resolution: {integrity: sha512-a/yTUkcO/Vyy+JffmAnTWbr4/90cLzw+CC3bRbhnULr/EM0fGNvM13oQQ14f2moLMcVDyAx/leczLlAOovhSZg==}
|
||||||
|
peerDependencies:
|
||||||
|
search-insights: '>= 1 < 3'
|
||||||
|
dependencies:
|
||||||
|
'@algolia/autocomplete-shared': 1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)
|
||||||
|
search-insights: 2.7.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@algolia/client-search'
|
||||||
|
- algoliasearch
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/autocomplete-preset-algolia@1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1):
|
||||||
|
resolution: {integrity: sha512-d4qlt6YmrLMYy95n5TB52wtNDr6EgAIPH81dvvvW8UmuWRgxEtY0NJiPwl/h95JtG2vmRM804M0DSwMCNZlzRA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@algolia/client-search': '>= 4.9.1 < 6'
|
||||||
|
algoliasearch: '>= 4.9.1 < 6'
|
||||||
|
dependencies:
|
||||||
|
'@algolia/autocomplete-shared': 1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)
|
||||||
|
'@algolia/client-search': 4.19.1
|
||||||
|
algoliasearch: 4.19.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/autocomplete-shared@1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1):
|
||||||
|
resolution: {integrity: sha512-Wnm9E4Ye6Rl6sTTqjoymD+l8DjSTHsHboVRYrKgEt8Q7UHm9nYbqhN/i0fhUYA3OAEH7WA8x3jfpnmJm3rKvaQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@algolia/client-search': '>= 4.9.1 < 6'
|
||||||
|
algoliasearch: '>= 4.9.1 < 6'
|
||||||
|
dependencies:
|
||||||
|
'@algolia/client-search': 4.19.1
|
||||||
|
algoliasearch: 4.19.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/cache-browser-local-storage@4.19.1:
|
||||||
|
resolution: {integrity: sha512-FYAZWcGsFTTaSAwj9Std8UML3Bu8dyWDncM7Ls8g+58UOe4XYdlgzXWbrIgjaguP63pCCbMoExKr61B+ztK3tw==}
|
||||||
|
dependencies:
|
||||||
|
'@algolia/cache-common': 4.19.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/cache-common@4.19.1:
|
||||||
|
resolution: {integrity: sha512-XGghi3l0qA38HiqdoUY+wvGyBsGvKZ6U3vTiMBT4hArhP3fOGLXpIINgMiiGjTe4FVlTa5a/7Zf2bwlIHfRqqg==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/cache-in-memory@4.19.1:
|
||||||
|
resolution: {integrity: sha512-+PDWL+XALGvIginigzu8oU6eWw+o76Z8zHbBovWYcrtWOEtinbl7a7UTt3x3lthv+wNuFr/YD1Gf+B+A9V8n5w==}
|
||||||
|
dependencies:
|
||||||
|
'@algolia/cache-common': 4.19.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/client-account@4.19.1:
|
||||||
|
resolution: {integrity: sha512-Oy0ritA2k7AMxQ2JwNpfaEcgXEDgeyKu0V7E7xt/ZJRdXfEpZcwp9TOg4TJHC7Ia62gIeT2Y/ynzsxccPw92GA==}
|
||||||
|
dependencies:
|
||||||
|
'@algolia/client-common': 4.19.1
|
||||||
|
'@algolia/client-search': 4.19.1
|
||||||
|
'@algolia/transporter': 4.19.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/client-analytics@4.19.1:
|
||||||
|
resolution: {integrity: sha512-5QCq2zmgdZLIQhHqwl55ZvKVpLM3DNWjFI4T+bHr3rGu23ew2bLO4YtyxaZeChmDb85jUdPDouDlCumGfk6wOg==}
|
||||||
|
dependencies:
|
||||||
|
'@algolia/client-common': 4.19.1
|
||||||
|
'@algolia/client-search': 4.19.1
|
||||||
|
'@algolia/requester-common': 4.19.1
|
||||||
|
'@algolia/transporter': 4.19.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/client-common@4.19.1:
|
||||||
|
resolution: {integrity: sha512-3kAIVqTcPrjfS389KQvKzliC559x+BDRxtWamVJt8IVp7LGnjq+aVAXg4Xogkur1MUrScTZ59/AaUd5EdpyXgA==}
|
||||||
|
dependencies:
|
||||||
|
'@algolia/requester-common': 4.19.1
|
||||||
|
'@algolia/transporter': 4.19.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/client-personalization@4.19.1:
|
||||||
|
resolution: {integrity: sha512-8CWz4/H5FA+krm9HMw2HUQenizC/DxUtsI5oYC0Jxxyce1vsr8cb1aEiSJArQT6IzMynrERif1RVWLac1m36xw==}
|
||||||
|
dependencies:
|
||||||
|
'@algolia/client-common': 4.19.1
|
||||||
|
'@algolia/requester-common': 4.19.1
|
||||||
|
'@algolia/transporter': 4.19.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/client-search@4.19.1:
|
||||||
|
resolution: {integrity: sha512-mBecfMFS4N+yK/p0ZbK53vrZbL6OtWMk8YmnOv1i0LXx4pelY8TFhqKoTit3NPVPwoSNN0vdSN9dTu1xr1XOVw==}
|
||||||
|
dependencies:
|
||||||
|
'@algolia/client-common': 4.19.1
|
||||||
|
'@algolia/requester-common': 4.19.1
|
||||||
|
'@algolia/transporter': 4.19.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/logger-common@4.19.1:
|
||||||
|
resolution: {integrity: sha512-i6pLPZW/+/YXKis8gpmSiNk1lOmYCmRI6+x6d2Qk1OdfvX051nRVdalRbEcVTpSQX6FQAoyeaui0cUfLYW5Elw==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/logger-console@4.19.1:
|
||||||
|
resolution: {integrity: sha512-jj72k9GKb9W0c7TyC3cuZtTr0CngLBLmc8trzZlXdfvQiigpUdvTi1KoWIb2ZMcRBG7Tl8hSb81zEY3zI2RlXg==}
|
||||||
|
dependencies:
|
||||||
|
'@algolia/logger-common': 4.19.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/requester-browser-xhr@4.19.1:
|
||||||
|
resolution: {integrity: sha512-09K/+t7lptsweRTueHnSnmPqIxbHMowejAkn9XIcJMLdseS3zl8ObnS5GWea86mu3vy4+8H+ZBKkUN82Zsq/zg==}
|
||||||
|
dependencies:
|
||||||
|
'@algolia/requester-common': 4.19.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/requester-common@4.19.1:
|
||||||
|
resolution: {integrity: sha512-BisRkcWVxrDzF1YPhAckmi2CFYK+jdMT60q10d7z3PX+w6fPPukxHRnZwooiTUrzFe50UBmLItGizWHP5bDzVQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/requester-node-http@4.19.1:
|
||||||
|
resolution: {integrity: sha512-6DK52DHviBHTG2BK/Vv2GIlEw7i+vxm7ypZW0Z7vybGCNDeWzADx+/TmxjkES2h15+FZOqVf/Ja677gePsVItA==}
|
||||||
|
dependencies:
|
||||||
|
'@algolia/requester-common': 4.19.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@algolia/transporter@4.19.1:
|
||||||
|
resolution: {integrity: sha512-nkpvPWbpuzxo1flEYqNIbGz7xhfhGOKGAZS7tzC+TELgEmi7z99qRyTfNSUlW7LZmB3ACdnqAo+9A9KFBENviQ==}
|
||||||
|
dependencies:
|
||||||
|
'@algolia/cache-common': 4.19.1
|
||||||
|
'@algolia/logger-common': 4.19.1
|
||||||
|
'@algolia/requester-common': 4.19.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@babel/helper-string-parser@7.22.5:
|
||||||
|
resolution: {integrity: sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@babel/helper-validator-identifier@7.22.5:
|
||||||
|
resolution: {integrity: sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@babel/parser@7.22.10:
|
||||||
|
resolution: {integrity: sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
dependencies:
|
||||||
|
'@babel/types': 7.22.10
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@babel/types@7.22.10:
|
||||||
|
resolution: {integrity: sha512-obaoigiLrlDZ7TUQln/8m4mSqIW2QFeOrCQc9r+xsaHGNoplVNYlRVpsfE8Vj35GEm2ZH4ZhrNYogs/3fj85kg==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
dependencies:
|
||||||
|
'@babel/helper-string-parser': 7.22.5
|
||||||
|
'@babel/helper-validator-identifier': 7.22.5
|
||||||
|
to-fast-properties: 2.0.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@docsearch/css@3.5.2:
|
||||||
|
resolution: {integrity: sha512-SPiDHaWKQZpwR2siD0KQUwlStvIAnEyK6tAE2h2Wuoq8ue9skzhlyVQ1ddzOxX6khULnAALDiR/isSF3bnuciA==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@docsearch/js@3.5.2(@algolia/client-search@4.19.1)(search-insights@2.7.0):
|
||||||
|
resolution: {integrity: sha512-p1YFTCDflk8ieHgFJYfmyHBki1D61+U9idwrLh+GQQMrBSP3DLGKpy0XUJtPjAOPltcVbqsTjiPFfH7JImjUNg==}
|
||||||
|
dependencies:
|
||||||
|
'@docsearch/react': 3.5.2(@algolia/client-search@4.19.1)(search-insights@2.7.0)
|
||||||
|
preact: 10.17.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@algolia/client-search'
|
||||||
|
- '@types/react'
|
||||||
|
- react
|
||||||
|
- react-dom
|
||||||
|
- search-insights
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@docsearch/react@3.5.2(@algolia/client-search@4.19.1)(search-insights@2.7.0):
|
||||||
|
resolution: {integrity: sha512-9Ahcrs5z2jq/DcAvYtvlqEBHImbm4YJI8M9y0x6Tqg598P40HTEkX7hsMcIuThI+hTFxRGZ9hll0Wygm2yEjng==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>= 16.8.0 < 19.0.0'
|
||||||
|
react: '>= 16.8.0 < 19.0.0'
|
||||||
|
react-dom: '>= 16.8.0 < 19.0.0'
|
||||||
|
search-insights: '>= 1 < 3'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
search-insights:
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@algolia/autocomplete-core': 1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)(search-insights@2.7.0)
|
||||||
|
'@algolia/autocomplete-preset-algolia': 1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)
|
||||||
|
'@docsearch/css': 3.5.2
|
||||||
|
algoliasearch: 4.19.1
|
||||||
|
search-insights: 2.7.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@algolia/client-search'
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@esbuild/android-arm64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/android-arm@0.18.20:
|
||||||
|
resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/android-x64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/darwin-arm64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/darwin-x64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/freebsd-arm64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/freebsd-x64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-arm64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-arm@0.18.20:
|
||||||
|
resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-ia32@0.18.20:
|
||||||
|
resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-loong64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-mips64el@0.18.20:
|
||||||
|
resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-ppc64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-riscv64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-s390x@0.18.20:
|
||||||
|
resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-x64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/netbsd-x64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/openbsd-x64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/sunos-x64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/win32-arm64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/win32-ia32@0.18.20:
|
||||||
|
resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/win32-x64@0.18.20:
|
||||||
|
resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@fontsource/fira-mono@5.0.8:
|
||||||
|
resolution: {integrity: sha512-8OJiUK2lzJjvDlkmamEfhtpL1cyFApg1Pk4kE5Pw5UTf1ETF3Yy/pprgwV5I+LQPDjuFvinsinT9xSUZ2b/zuQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@fontsource/firago@5.0.7:
|
||||||
|
resolution: {integrity: sha512-xuTYVOBSwev2IVp2dqgrnq3gABUnehn91Ii+R1TM5Jpvr86gCPrMxmqfL9fgpUb5r12u7U1LBVC20GypIy8jeg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@jridgewell/sourcemap-codec@1.4.15:
|
||||||
|
resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@types/web-bluetooth@0.0.17:
|
||||||
|
resolution: {integrity: sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vitejs/plugin-vue@4.3.1(vite@4.4.9)(vue@3.3.4):
|
||||||
|
resolution: {integrity: sha512-tUBEtWcF7wFtII7ayNiLNDTCE1X1afySEo+XNVMNkFXaThENyCowIEX095QqbJZGTgoOcSVDJGlnde2NG4jtbQ==}
|
||||||
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
vite: ^4.0.0
|
||||||
|
vue: ^3.2.25
|
||||||
|
dependencies:
|
||||||
|
vite: 4.4.9
|
||||||
|
vue: 3.3.4
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vue/compiler-core@3.3.4:
|
||||||
|
resolution: {integrity: sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==}
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.22.10
|
||||||
|
'@vue/shared': 3.3.4
|
||||||
|
estree-walker: 2.0.2
|
||||||
|
source-map-js: 1.0.2
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vue/compiler-dom@3.3.4:
|
||||||
|
resolution: {integrity: sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==}
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-core': 3.3.4
|
||||||
|
'@vue/shared': 3.3.4
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vue/compiler-sfc@3.3.4:
|
||||||
|
resolution: {integrity: sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==}
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.22.10
|
||||||
|
'@vue/compiler-core': 3.3.4
|
||||||
|
'@vue/compiler-dom': 3.3.4
|
||||||
|
'@vue/compiler-ssr': 3.3.4
|
||||||
|
'@vue/reactivity-transform': 3.3.4
|
||||||
|
'@vue/shared': 3.3.4
|
||||||
|
estree-walker: 2.0.2
|
||||||
|
magic-string: 0.30.2
|
||||||
|
postcss: 8.4.28
|
||||||
|
source-map-js: 1.0.2
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vue/compiler-ssr@3.3.4:
|
||||||
|
resolution: {integrity: sha512-m0v6oKpup2nMSehwA6Uuu+j+wEwcy7QmwMkVNVfrV9P2qE5KshC6RwOCq8fjGS/Eak/uNb8AaWekfiXxbBB6gQ==}
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-dom': 3.3.4
|
||||||
|
'@vue/shared': 3.3.4
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vue/devtools-api@6.5.0:
|
||||||
|
resolution: {integrity: sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vue/reactivity-transform@3.3.4:
|
||||||
|
resolution: {integrity: sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==}
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.22.10
|
||||||
|
'@vue/compiler-core': 3.3.4
|
||||||
|
'@vue/shared': 3.3.4
|
||||||
|
estree-walker: 2.0.2
|
||||||
|
magic-string: 0.30.2
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vue/reactivity@3.3.4:
|
||||||
|
resolution: {integrity: sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==}
|
||||||
|
dependencies:
|
||||||
|
'@vue/shared': 3.3.4
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vue/runtime-core@3.3.4:
|
||||||
|
resolution: {integrity: sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==}
|
||||||
|
dependencies:
|
||||||
|
'@vue/reactivity': 3.3.4
|
||||||
|
'@vue/shared': 3.3.4
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vue/runtime-dom@3.3.4:
|
||||||
|
resolution: {integrity: sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==}
|
||||||
|
dependencies:
|
||||||
|
'@vue/runtime-core': 3.3.4
|
||||||
|
'@vue/shared': 3.3.4
|
||||||
|
csstype: 3.1.2
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vue/server-renderer@3.3.4(vue@3.3.4):
|
||||||
|
resolution: {integrity: sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: 3.3.4
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-ssr': 3.3.4
|
||||||
|
'@vue/shared': 3.3.4
|
||||||
|
vue: 3.3.4
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vue/shared@3.3.4:
|
||||||
|
resolution: {integrity: sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vueuse/core@10.3.0(vue@3.3.4):
|
||||||
|
resolution: {integrity: sha512-BEM5yxcFKb5btFjTSAFjTu5jmwoW66fyV9uJIP4wUXXU8aR5Hl44gndaaXp7dC5HSObmgbnR2RN+Un1p68Mf5Q==}
|
||||||
|
dependencies:
|
||||||
|
'@types/web-bluetooth': 0.0.17
|
||||||
|
'@vueuse/metadata': 10.3.0
|
||||||
|
'@vueuse/shared': 10.3.0(vue@3.3.4)
|
||||||
|
vue-demi: 0.14.5(vue@3.3.4)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- vue
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vueuse/integrations@10.3.0(focus-trap@7.5.2)(vue@3.3.4):
|
||||||
|
resolution: {integrity: sha512-Jgiv7oFyIgC6BxmDtiyG/fxyGysIds00YaY7sefwbhCZ2/tjEx1W/1WcsISSJPNI30in28+HC2J4uuU8184ekg==}
|
||||||
|
peerDependencies:
|
||||||
|
async-validator: '*'
|
||||||
|
axios: '*'
|
||||||
|
change-case: '*'
|
||||||
|
drauu: '*'
|
||||||
|
focus-trap: '*'
|
||||||
|
fuse.js: '*'
|
||||||
|
idb-keyval: '*'
|
||||||
|
jwt-decode: '*'
|
||||||
|
nprogress: '*'
|
||||||
|
qrcode: '*'
|
||||||
|
sortablejs: '*'
|
||||||
|
universal-cookie: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
async-validator:
|
||||||
|
optional: true
|
||||||
|
axios:
|
||||||
|
optional: true
|
||||||
|
change-case:
|
||||||
|
optional: true
|
||||||
|
drauu:
|
||||||
|
optional: true
|
||||||
|
focus-trap:
|
||||||
|
optional: true
|
||||||
|
fuse.js:
|
||||||
|
optional: true
|
||||||
|
idb-keyval:
|
||||||
|
optional: true
|
||||||
|
jwt-decode:
|
||||||
|
optional: true
|
||||||
|
nprogress:
|
||||||
|
optional: true
|
||||||
|
qrcode:
|
||||||
|
optional: true
|
||||||
|
sortablejs:
|
||||||
|
optional: true
|
||||||
|
universal-cookie:
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@vueuse/core': 10.3.0(vue@3.3.4)
|
||||||
|
'@vueuse/shared': 10.3.0(vue@3.3.4)
|
||||||
|
focus-trap: 7.5.2
|
||||||
|
vue-demi: 0.14.5(vue@3.3.4)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- vue
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vueuse/metadata@10.3.0:
|
||||||
|
resolution: {integrity: sha512-Ema3YhNOa4swDsV0V7CEY5JXvK19JI/o1szFO1iWxdFg3vhdFtCtSTP26PCvbUpnUtNHBY2wx5y3WDXND5Pvnw==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@vueuse/shared@10.3.0(vue@3.3.4):
|
||||||
|
resolution: {integrity: sha512-kGqCTEuFPMK4+fNWy6dUOiYmxGcUbtznMwBZLC1PubidF4VZY05B+Oht7Jh7/6x4VOWGpvu3R37WHi81cKpiqg==}
|
||||||
|
dependencies:
|
||||||
|
vue-demi: 0.14.5(vue@3.3.4)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- vue
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/algoliasearch@4.19.1:
|
||||||
|
resolution: {integrity: sha512-IJF5b93b2MgAzcE/tuzW0yOPnuUyRgGAtaPv5UUywXM8kzqfdwZTO4sPJBzoGz1eOy6H9uEchsJsBFTELZSu+g==}
|
||||||
|
dependencies:
|
||||||
|
'@algolia/cache-browser-local-storage': 4.19.1
|
||||||
|
'@algolia/cache-common': 4.19.1
|
||||||
|
'@algolia/cache-in-memory': 4.19.1
|
||||||
|
'@algolia/client-account': 4.19.1
|
||||||
|
'@algolia/client-analytics': 4.19.1
|
||||||
|
'@algolia/client-common': 4.19.1
|
||||||
|
'@algolia/client-personalization': 4.19.1
|
||||||
|
'@algolia/client-search': 4.19.1
|
||||||
|
'@algolia/logger-common': 4.19.1
|
||||||
|
'@algolia/logger-console': 4.19.1
|
||||||
|
'@algolia/requester-browser-xhr': 4.19.1
|
||||||
|
'@algolia/requester-common': 4.19.1
|
||||||
|
'@algolia/requester-node-http': 4.19.1
|
||||||
|
'@algolia/transporter': 4.19.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/ansi-sequence-parser@1.1.1:
|
||||||
|
resolution: {integrity: sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/body-scroll-lock@4.0.0-beta.0:
|
||||||
|
resolution: {integrity: sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/csstype@3.1.2:
|
||||||
|
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/esbuild@0.18.20:
|
||||||
|
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
requiresBuild: true
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/android-arm': 0.18.20
|
||||||
|
'@esbuild/android-arm64': 0.18.20
|
||||||
|
'@esbuild/android-x64': 0.18.20
|
||||||
|
'@esbuild/darwin-arm64': 0.18.20
|
||||||
|
'@esbuild/darwin-x64': 0.18.20
|
||||||
|
'@esbuild/freebsd-arm64': 0.18.20
|
||||||
|
'@esbuild/freebsd-x64': 0.18.20
|
||||||
|
'@esbuild/linux-arm': 0.18.20
|
||||||
|
'@esbuild/linux-arm64': 0.18.20
|
||||||
|
'@esbuild/linux-ia32': 0.18.20
|
||||||
|
'@esbuild/linux-loong64': 0.18.20
|
||||||
|
'@esbuild/linux-mips64el': 0.18.20
|
||||||
|
'@esbuild/linux-ppc64': 0.18.20
|
||||||
|
'@esbuild/linux-riscv64': 0.18.20
|
||||||
|
'@esbuild/linux-s390x': 0.18.20
|
||||||
|
'@esbuild/linux-x64': 0.18.20
|
||||||
|
'@esbuild/netbsd-x64': 0.18.20
|
||||||
|
'@esbuild/openbsd-x64': 0.18.20
|
||||||
|
'@esbuild/sunos-x64': 0.18.20
|
||||||
|
'@esbuild/win32-arm64': 0.18.20
|
||||||
|
'@esbuild/win32-ia32': 0.18.20
|
||||||
|
'@esbuild/win32-x64': 0.18.20
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/estree-walker@2.0.2:
|
||||||
|
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/focus-trap@7.5.2:
|
||||||
|
resolution: {integrity: sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==}
|
||||||
|
dependencies:
|
||||||
|
tabbable: 6.2.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/fsevents@2.3.2:
|
||||||
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/jsonc-parser@3.2.0:
|
||||||
|
resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/magic-string@0.30.2:
|
||||||
|
resolution: {integrity: sha512-lNZdu7pewtq/ZvWUp9Wpf/x7WzMTsR26TWV03BRZrXFsv+BI6dy8RAiKgm1uM/kyR0rCfUcqvOlXKG66KhIGug==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/sourcemap-codec': 1.4.15
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/mark.js@8.11.1:
|
||||||
|
resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/minisearch@6.1.0:
|
||||||
|
resolution: {integrity: sha512-PNxA/X8pWk+TiqPbsoIYH0GQ5Di7m6326/lwU/S4mlo4wGQddIcf/V//1f9TB0V4j59b57b+HZxt8h3iMROGvg==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/nanoid@3.3.6:
|
||||||
|
resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
|
||||||
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
hasBin: true
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/picocolors@1.0.0:
|
||||||
|
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/postcss@8.4.28:
|
||||||
|
resolution: {integrity: sha512-Z7V5j0cq8oEKyejIKfpD8b4eBy9cwW2JWPk0+fB1HOAMsfHbnAXLLS+PfVWlzMSLQaWttKDt607I0XHmpE67Vw==}
|
||||||
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
dependencies:
|
||||||
|
nanoid: 3.3.6
|
||||||
|
picocolors: 1.0.0
|
||||||
|
source-map-js: 1.0.2
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/preact@10.17.1:
|
||||||
|
resolution: {integrity: sha512-X9BODrvQ4Ekwv9GURm9AKAGaomqXmip7NQTZgY7gcNmr7XE83adOMJvd3N42id1tMFU7ojiynRsYnY6/BRFxLA==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/rollup@3.28.0:
|
||||||
|
resolution: {integrity: sha512-d7zhvo1OUY2SXSM6pfNjgD5+d0Nz87CUp4mt8l/GgVP3oBsPwzNvSzyu1me6BSG9JIgWNTVcafIXBIyM8yQ3yw==}
|
||||||
|
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/search-insights@2.7.0:
|
||||||
|
resolution: {integrity: sha512-GLbVaGgzYEKMvuJbHRhLi1qoBFnjXZGZ6l4LxOYPCp4lI2jDRB3jPU9/XNhMwv6kvnA9slTreq6pvK+b3o3aqg==}
|
||||||
|
engines: {node: '>=8.16.0'}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/shiki@0.14.3:
|
||||||
|
resolution: {integrity: sha512-U3S/a+b0KS+UkTyMjoNojvTgrBHjgp7L6ovhFVZsXmBGnVdQ4K4U9oK0z63w538S91ATngv1vXigHCSWOwnr+g==}
|
||||||
|
dependencies:
|
||||||
|
ansi-sequence-parser: 1.1.1
|
||||||
|
jsonc-parser: 3.2.0
|
||||||
|
vscode-oniguruma: 1.7.0
|
||||||
|
vscode-textmate: 8.0.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/source-map-js@1.0.2:
|
||||||
|
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/tabbable@6.2.0:
|
||||||
|
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/to-fast-properties@2.0.0:
|
||||||
|
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/vite@4.4.9:
|
||||||
|
resolution: {integrity: sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==}
|
||||||
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>= 14'
|
||||||
|
less: '*'
|
||||||
|
lightningcss: ^1.21.0
|
||||||
|
sass: '*'
|
||||||
|
stylus: '*'
|
||||||
|
sugarss: '*'
|
||||||
|
terser: ^5.4.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
less:
|
||||||
|
optional: true
|
||||||
|
lightningcss:
|
||||||
|
optional: true
|
||||||
|
sass:
|
||||||
|
optional: true
|
||||||
|
stylus:
|
||||||
|
optional: true
|
||||||
|
sugarss:
|
||||||
|
optional: true
|
||||||
|
terser:
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.18.20
|
||||||
|
postcss: 8.4.28
|
||||||
|
rollup: 3.28.0
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/vitepress@1.0.0-rc.4(@algolia/client-search@4.19.1)(search-insights@2.7.0):
|
||||||
|
resolution: {integrity: sha512-JCQ89Bm6ECUTnyzyas3JENo00UDJeK8q1SUQyJYou+4Yz5BKEc/F3O21cu++DnUT2zXc0kvQ2Aj4BZCc/nioXQ==}
|
||||||
|
hasBin: true
|
||||||
|
dependencies:
|
||||||
|
'@docsearch/css': 3.5.2
|
||||||
|
'@docsearch/js': 3.5.2(@algolia/client-search@4.19.1)(search-insights@2.7.0)
|
||||||
|
'@vitejs/plugin-vue': 4.3.1(vite@4.4.9)(vue@3.3.4)
|
||||||
|
'@vue/devtools-api': 6.5.0
|
||||||
|
'@vueuse/core': 10.3.0(vue@3.3.4)
|
||||||
|
'@vueuse/integrations': 10.3.0(focus-trap@7.5.2)(vue@3.3.4)
|
||||||
|
body-scroll-lock: 4.0.0-beta.0
|
||||||
|
focus-trap: 7.5.2
|
||||||
|
mark.js: 8.11.1
|
||||||
|
minisearch: 6.1.0
|
||||||
|
shiki: 0.14.3
|
||||||
|
vite: 4.4.9
|
||||||
|
vue: 3.3.4
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@algolia/client-search'
|
||||||
|
- '@types/node'
|
||||||
|
- '@types/react'
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- async-validator
|
||||||
|
- axios
|
||||||
|
- change-case
|
||||||
|
- drauu
|
||||||
|
- fuse.js
|
||||||
|
- idb-keyval
|
||||||
|
- jwt-decode
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- nprogress
|
||||||
|
- qrcode
|
||||||
|
- react
|
||||||
|
- react-dom
|
||||||
|
- sass
|
||||||
|
- search-insights
|
||||||
|
- sortablejs
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- terser
|
||||||
|
- universal-cookie
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/vscode-oniguruma@1.7.0:
|
||||||
|
resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/vscode-textmate@8.0.0:
|
||||||
|
resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/vue-demi@0.14.5(vue@3.3.4):
|
||||||
|
resolution: {integrity: sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
requiresBuild: true
|
||||||
|
peerDependencies:
|
||||||
|
'@vue/composition-api': ^1.0.0-rc.1
|
||||||
|
vue: ^3.0.0-0 || ^2.6.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@vue/composition-api':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
vue: 3.3.4
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/vue@3.3.4:
|
||||||
|
resolution: {integrity: sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==}
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-dom': 3.3.4
|
||||||
|
'@vue/compiler-sfc': 3.3.4
|
||||||
|
'@vue/runtime-dom': 3.3.4
|
||||||
|
'@vue/server-renderer': 3.3.4(vue@3.3.4)
|
||||||
|
'@vue/shared': 3.3.4
|
||||||
|
dev: true
|
1
docs/public/logo.svg
Normal file
1
docs/public/logo.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg width="11.411mm" height="11.076mm" version="1.1" viewBox="1 1 11.245 10.218" xmlns="http://www.w3.org/2000/svg"><defs><clipPath id="clipPath16"><path d="m0 38h38v-38h-38z"/></clipPath></defs><g transform="translate(-49.754 -142.39)"><g transform="matrix(.33073 0 0 -.33073 50.093 154.62)" clip-path="url(#clipPath16)"><path d="m35.347 20.107-8.899 3.294-3.323 10.891c-0.128 0.42-0.516 0.708-0.956 0.708-0.439 0-0.828-0.288-0.956-0.708l-3.322-10.891-8.9-3.294c-0.393-0.146-0.653-0.52-0.653-0.938s0.26-0.793 0.653-0.937l8.896-3.293 3.323-11.223c0.126-0.425 0.516-0.716 0.959-0.716s0.833 0.291 0.959 0.716l3.324 11.223 8.896 3.293c0.392 0.144 0.652 0.519 0.652 0.937s-0.26 0.792-0.653 0.938" fill="#aa8ed6"/><path d="m15.347 9.1064-2.313 0.856-0.9 3.3c-0.119 0.436-0.514 0.738-0.965 0.738s-0.846-0.302-0.965-0.738l-0.9-3.3-2.313-0.856c-0.393-0.145-0.653-0.52-0.653-0.937 0-0.418 0.26-0.793 0.653-0.938l2.301-0.853 0.907-3.622c0.111-0.444 0.511-0.756 0.97-0.756 0.458 0 0.858 0.312 0.97 0.756l0.907 3.622 2.301 0.853c0.393 0.145 0.653 0.52 0.653 0.938 0 0.417-0.26 0.792-0.653 0.937" fill="#fcab40"/><path d="m11.009 30.769-2.365 0.875-0.875 2.365c-0.146 0.393-0.52 0.653-0.938 0.653-0.419 0-0.793-0.26-0.938-0.653l-0.876-2.365-2.364-0.875c-0.393-0.146-0.653-0.52-0.653-0.938s0.26-0.792 0.653-0.938l2.364-0.875 0.876-2.365c0.145-0.393 0.519-0.653 0.938-0.653 0.418 0 0.792 0.26 0.938 0.653l0.875 2.365 2.365 0.875c0.393 0.146 0.653 0.52 0.653 0.938s-0.26 0.792-0.653 0.938" fill="#5dadec"/></g></g></svg>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -1,6 +1,6 @@
|
||||||
# Running pronouns.cc in production
|
# Running pronouns.cc in production
|
||||||
|
|
||||||
The configuration files in this directory are the same files used to run pronouns.cc in production.
|
The configuration files in this directory are the same files used to run pronouns.cc in production.
|
||||||
You might have to change paths and ports, but they should work fine as-is.
|
You might have to change paths and ports, but they should work fine as-is.
|
||||||
|
|
||||||
## Building pronouns.cc
|
## Building pronouns.cc
|
|
@ -1,4 +1,4 @@
|
||||||
# Base of frontend URLs
|
# Base of frontend URLs (required)
|
||||||
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,20 +1,31 @@
|
||||||
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,26 +16,22 @@ writeFileSync("src/icons.ts", `const icons = ${output};\nexport default icons;`)
|
||||||
const goCode1 = `// Generated code. DO NOT EDIT
|
const goCode1 = `// Generated code. DO NOT EDIT
|
||||||
package icons
|
package icons
|
||||||
|
|
||||||
var icons = [...]string{
|
var icons = map[string]struct{}{
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const goCode2 = `}
|
const goCode2 = `}
|
||||||
|
|
||||||
// IsValid returns true if the input is the name of a Bootstrap icon.
|
// IsValid returns true if the input is the name of a Bootstrap icon.
|
||||||
func IsValid(name string) bool {
|
func IsValid(name string) bool {
|
||||||
for i := range icons {
|
_, ok := icons[name]
|
||||||
if icons[i] == name {
|
return ok
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let goOutput = goCode1;
|
let goOutput = goCode1;
|
||||||
|
|
||||||
keys.forEach((element) => {
|
keys.forEach((element) => {
|
||||||
goOutput += ` "${element}",\n`;
|
goOutput += ` "${element}": {},\n`;
|
||||||
});
|
});
|
||||||
|
|
||||||
goOutput += goCode2;
|
goOutput += goCode2;
|
||||||
|
|
|
@ -1,52 +1,53 @@
|
||||||
{
|
{
|
||||||
"name": "pronouns-fe",
|
"name": "pronouns-fe",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||||
"format": "prettier --plugin-search-dir . --write ."
|
"format": "prettier --plugin-search-dir . --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^2.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/adapter-node": "^1.2.3",
|
"@sveltejs/adapter-node": "^2.0.0",
|
||||||
"@sveltejs/kit": "^1.15.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"@types/luxon": "^3.2.2",
|
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||||
"@types/markdown-it": "^12.2.3",
|
"@sveltestrap/sveltestrap": "^6.0.5",
|
||||||
"@types/node": "^18.15.11",
|
"@types/luxon": "^3.3.7",
|
||||||
"@types/sanitize-html": "^2.9.0",
|
"@types/markdown-it": "^13.0.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
"@types/node": "^18.15.11",
|
||||||
"@typescript-eslint/parser": "^5.57.1",
|
"@types/sanitize-html": "^2.9.0",
|
||||||
"eslint": "^8.37.0",
|
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"@typescript-eslint/parser": "^5.57.1",
|
||||||
"eslint-plugin-svelte3": "^4.0.0",
|
"eslint": "^8.37.0",
|
||||||
"prettier": "^2.8.7",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"prettier-plugin-svelte": "^2.10.0",
|
"eslint-plugin-svelte3": "^4.0.0",
|
||||||
"svelte": "^3.58.0",
|
"prettier": "^2.8.7",
|
||||||
"svelte-check": "^3.1.4",
|
"prettier-plugin-svelte": "^2.10.1",
|
||||||
"svelte-hcaptcha": "^0.1.1",
|
"svelte": "^4.0.0",
|
||||||
"sveltestrap": "^5.10.0",
|
"svelte-check": "^3.4.3",
|
||||||
"tslib": "^2.5.0",
|
"svelte-hcaptcha": "^0.1.1",
|
||||||
"typescript": "^4.9.5",
|
"tslib": "^2.5.0",
|
||||||
"vite": "^4.2.1",
|
"typescript": "^5.0.0",
|
||||||
"vite-plugin-markdown": "^2.1.0"
|
"vite": "^5.0.0",
|
||||||
},
|
"vite-plugin-markdown": "^2.1.0"
|
||||||
"type": "module",
|
},
|
||||||
"dependencies": {
|
"type": "module",
|
||||||
"@fontsource/firago": "^4.5.3",
|
"dependencies": {
|
||||||
"@popperjs/core": "^2.11.7",
|
"@fontsource/firago": "^4.5.3",
|
||||||
"@sentry/node": "^7.46.0",
|
"@popperjs/core": "^2.11.7",
|
||||||
"base64-arraybuffer": "^1.0.2",
|
"@sentry/node": "^7.46.0",
|
||||||
"bootstrap": "5.3.0-alpha1",
|
"base64-arraybuffer": "^1.0.2",
|
||||||
"bootstrap-icons": "^1.10.4",
|
"bootstrap": "^5.3.2",
|
||||||
"jose": "^4.13.1",
|
"bootstrap-icons": "^1.11.2",
|
||||||
"luxon": "^3.3.0",
|
"jose": "^4.13.1",
|
||||||
"markdown-it": "^13.0.1",
|
"luxon": "^3.3.0",
|
||||||
"pretty-bytes": "^6.1.0",
|
"markdown-it": "^13.0.1",
|
||||||
"sanitize-html": "^2.10.0"
|
"pretty-bytes": "^6.1.0",
|
||||||
}
|
"sanitize-html": "^2.10.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
28
frontend/src/app.d.ts
vendored
28
frontend/src/app.d.ts
vendored
|
@ -1,31 +1,19 @@
|
||||||
// See https://kit.svelte.dev/docs/types#app
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
|
|
||||||
|
import type { ErrorCode } from "$lib/api/entities";
|
||||||
|
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
interface Error {
|
||||||
|
code: ErrorCode;
|
||||||
|
message?: string | undefined;
|
||||||
|
details?: string | undefined;
|
||||||
|
}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,7 +2,9 @@ import { PRIVATE_SENTRY_DSN } from "$env/static/private";
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
import type { HandleServerError } from "@sveltejs/kit";
|
import type { HandleServerError } from "@sveltejs/kit";
|
||||||
|
|
||||||
Sentry.init({ dsn: PRIVATE_SENTRY_DSN });
|
if (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,11 +1,14 @@
|
||||||
|
/* 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;
|
||||||
export const MAX_FIELDS = 25;
|
export const MAX_FIELDS = 25;
|
||||||
export const MAX_DESCRIPTION_LENGTH = 1000;
|
export const MAX_DESCRIPTION_LENGTH = 1000;
|
||||||
|
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;
|
||||||
|
@ -14,6 +17,7 @@ export interface User {
|
||||||
links: string[];
|
links: string[];
|
||||||
member_title: string | null;
|
member_title: string | null;
|
||||||
badges: number;
|
badges: number;
|
||||||
|
utc_offset: number | null;
|
||||||
|
|
||||||
names: FieldEntry[];
|
names: FieldEntry[];
|
||||||
pronouns: Pronoun[];
|
pronouns: Pronoun[];
|
||||||
|
@ -56,10 +60,17 @@ export interface MeUser extends User {
|
||||||
fediverse_instance: string | null;
|
fediverse_instance: string | null;
|
||||||
list_private: boolean;
|
list_private: boolean;
|
||||||
last_sid_reroll: string;
|
last_sid_reroll: string;
|
||||||
|
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;
|
name: string | null;
|
||||||
entries: FieldEntry[];
|
entries: FieldEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,6 +87,7 @@ 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;
|
||||||
|
@ -96,6 +108,7 @@ 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;
|
||||||
|
@ -104,6 +117,7 @@ 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,9 +11,16 @@ export async function apiFetch<T>(
|
||||||
body,
|
body,
|
||||||
token,
|
token,
|
||||||
headers,
|
headers,
|
||||||
}: { method?: string; body?: any; token?: string; headers?: Record<string, string> },
|
version,
|
||||||
|
}: {
|
||||||
|
method?: string;
|
||||||
|
body?: any;
|
||||||
|
token?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
version?: number;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, {
|
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v${version || 1}${path}`, {
|
||||||
method: method || "GET",
|
method: method || "GET",
|
||||||
headers: {
|
headers: {
|
||||||
...(token ? { Authorization: token } : {}),
|
...(token ? { Authorization: token } : {}),
|
||||||
|
@ -28,12 +35,18 @@ export async function apiFetch<T>(
|
||||||
return data as T;
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiFetchClient = async <T>(path: string, method = "GET", body: any = null) => {
|
export const apiFetchClient = async <T>(
|
||||||
|
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) {
|
||||||
|
@ -55,9 +68,16 @@ export async function fastFetch(
|
||||||
body,
|
body,
|
||||||
token,
|
token,
|
||||||
headers,
|
headers,
|
||||||
}: { method?: string; body?: any; token?: string; headers?: Record<string, string> },
|
version,
|
||||||
|
}: {
|
||||||
|
method?: string;
|
||||||
|
body?: any;
|
||||||
|
token?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
version?: number;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, {
|
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v${version || 1}${path}`, {
|
||||||
method: method || "GET",
|
method: method || "GET",
|
||||||
headers: {
|
headers: {
|
||||||
...(token ? { Authorization: token } : {}),
|
...(token ? { Authorization: token } : {}),
|
||||||
|
@ -71,12 +91,18 @@ 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 (path: string, method = "GET", body: any = null) => {
|
export const fastFetchClient = async (
|
||||||
|
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,6 +11,7 @@ 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 {
|
||||||
|
|
10
frontend/src/lib/components/ActiveLink.svelte
Normal file
10
frontend/src/lib/components/ActiveLink.svelte
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { NavLink } from "@sveltestrap/sveltestrap";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
|
||||||
|
export let href: string;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavLink {href} active={$page.url.pathname === href}>
|
||||||
|
<slot />
|
||||||
|
</NavLink>
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { APIError } from "$lib/api/entities";
|
import type { APIError } from "$lib/api/entities";
|
||||||
import { Alert } from "sveltestrap";
|
import { Alert } from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
export let error: APIError;
|
export let error: APIError;
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
export let urls: string[];
|
export let urls: string[];
|
||||||
export let alt: string;
|
export let alt: string;
|
||||||
export let width = 300;
|
export let width = 300;
|
||||||
|
export let lazyLoad = false;
|
||||||
|
|
||||||
const contentTypeFor = (url: string) => {
|
const contentTypeFor = (url: string) => {
|
||||||
if (url.endsWith(".webp")) {
|
if (url.endsWith(".webp")) {
|
||||||
|
@ -31,6 +32,7 @@
|
||||||
src={urls[0] || defaultAvatars[0]}
|
src={urls[0] || defaultAvatars[0]}
|
||||||
{alt}
|
{alt}
|
||||||
class="rounded-circle img-fluid"
|
class="rounded-circle img-fluid"
|
||||||
|
loading={lazyLoad ? "lazy" : "eager"}
|
||||||
/>
|
/>
|
||||||
</picture>
|
</picture>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button, Icon, Tooltip } from "sveltestrap";
|
import { Button, Icon, Tooltip } from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
export let icon: string;
|
export let icon: string;
|
||||||
export let color: "primary" | "secondary" | "success" | "danger";
|
export let color: "primary" | "secondary" | "success" | "danger";
|
||||||
|
|
|
@ -6,12 +6,12 @@
|
||||||
type User,
|
type User,
|
||||||
type CustomPreferences,
|
type CustomPreferences,
|
||||||
} from "$lib/api/entities";
|
} from "$lib/api/entities";
|
||||||
import { Icon, Tooltip } from "sveltestrap";
|
import { Icon, Tooltip } from "@sveltestrap/sveltestrap";
|
||||||
import FallbackImage from "./FallbackImage.svelte";
|
import FallbackImage from "./FallbackImage.svelte";
|
||||||
|
|
||||||
export let user: User;
|
export let user: User;
|
||||||
export let member: PartialMember & {
|
export let member: PartialMember & {
|
||||||
unlisted?: boolean
|
unlisted?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let pronouns: string | undefined;
|
let pronouns: string | undefined;
|
||||||
|
@ -46,13 +46,18 @@
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<a href="/@{user.name}/{member.name}">
|
<a href="/@{user.name}/{member.name}">
|
||||||
<FallbackImage urls={memberAvatars(member)} width={200} alt="Avatar for {member.name}" />
|
<FallbackImage
|
||||||
|
urls={memberAvatars(member)}
|
||||||
|
width={200}
|
||||||
|
lazyLoad
|
||||||
|
alt="Avatar for {member.name}"
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
<p class="m-2">
|
<p class="m-2">
|
||||||
<a class="text-reset fs-5 text-break" href="/@{user.name}/{member.name}">
|
<a class="text-reset fs-5 text-break" href="/@{user.name}/{member.name}">
|
||||||
{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";
|
import { Icon, Tooltip } from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
import type { CustomPreference, CustomPreferences } from "$lib/api/entities";
|
import type { CustomPreference, CustomPreferences } from "$lib/api/entities";
|
||||||
import defaultPreferences from "$lib/api/default_preferences";
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
|
@ -15,10 +15,11 @@
|
||||||
$: currentPreference =
|
$: currentPreference =
|
||||||
status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing;
|
status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing;
|
||||||
|
|
||||||
let iconElement: HTMLElement;
|
let iconElement: HTMLSpanElement;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span bind:this={iconElement} tabindex={0}
|
<span bind:this={iconElement} aria-hidden>
|
||||||
><Icon name={currentPreference.icon} class={className} /></span
|
<Icon name={currentPreference.icon} class={className} />
|
||||||
>
|
</span>
|
||||||
<Tooltip target={iconElement} placement="top">{currentPreference.tooltip}</Tooltip>
|
<span class="visually-hidden">{currentPreference.tooltip}:</span>
|
||||||
|
<Tooltip aria-hidden target={iconElement} placement="top">{currentPreference.tooltip}</Tooltip>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue