Compare commits

..

No commits in common. "main" and "feature/custom-preferences" have entirely different histories.

263 changed files with 5960 additions and 19350 deletions

View file

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

View file

@ -1,44 +0,0 @@
# Key used to sign tokens. Generate this with `go run . generate key`
HMAC_KEY=
# PostgreSQL connection URL (postgresql://user:pass@host:port/dbname)
DATABASE_URL=
# Redis connection URL (redis://user:pass@host:port)
REDIS=
# Port for the backend to listen on; frontend assumes this will be 8080 for dev
PORT=8080
# Frontend base URL, used to construct URLs that point back to the frontend
BASE_URL=http://localhost:5173
# S3/MinIO configuration, required for avatars, pride flags, and data exports
# Note: MINIO_ENDPOINT must be set and look like a minio endpoint, but doesn't
# have to actually point to anything real
MINIO_ENDPOINT=example.com
MINIO_BUCKET=
MINIO_ACCESS_KEY_ID=
MINIO_ACCESS_KEY_SECRET=
MINIO_SSL=
# IP address of the frontend; requests from here will never be ratelimited
FRONTEND_IP=
# Auth providers - fill in OAuth app info to enable OAuth login for each
# https://discord.com/developers/applications
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# https://developers.google.com/identity/protocols/oauth2#basicsteps
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# https://www.tumblr.com/oauth/apps
TUMBLR_CLIENT_ID=
TUMBLR_CLIENT_SECRET=
# Discord bot config - provide the app's public key in addition to client ID/
# secret above to let the bot respond to command interactions over HTTP
DISCORD_PUBLIC_KEY=

3
.gitignore vendored
View file

@ -11,6 +11,3 @@ build
package
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
target
tmp
seed.yaml

View file

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

View file

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

2299
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +0,0 @@
[workspace]
members = [
"pronounslib",
"prns",
]

View file

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

View file

@ -25,25 +25,17 @@ Requirements:
- PostgreSQL (any currently supported version should work)
- Redis 6.0 or later
- Node.js (latest version)
- MinIO **if using avatars, flags, or data exports** (_not_ required otherwise)
- [Air](https://github.com/cosmtrek/air) for live reloading the backend
- MinIO **if using avatars or data exports** (_not_ required otherwise)
### 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;`
2. Copy `.env.example` in the repository root to a new file named `.env` and fill out the required options.
3. Copy `frontend/.env.example` to `frontend/env` and fill out the required options.
4. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
5. Run `pnpm dev`. Alternatively, if you don't want the backend to live reload, run `go run -v . web`,
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.
### 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`.
2. Create a `.env` file in the repository root containing at least `HMAC_KEY`, `DATABASE_URL`, `REDIS`, `PORT`, and `MINIO_ENDPOINT` keys.
3. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
4. Run `go run -v . web` to run the backend.
5. Create `frontend/.env` with the following content: `PUBLIC_BASE_URL=http://localhost:5173`
6. cd into the `frontend` directory and run `pnpm dev` to run the frontend.
## License

View file

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

View file

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

View file

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

View file

@ -6,20 +6,23 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"image"
_ "image/gif"
"image/jpeg"
_ "image/png"
"io"
"strings"
"emperror.dev/errors"
"github.com/davidbyttow/govips/v2/vips"
"github.com/disintegration/imaging"
"github.com/minio/minio-go/v7"
"github.com/rs/xid"
"github.com/chai2010/webp"
)
const ErrInvalidDataURI = errors.Sentinel("invalid data URI")
const ErrInvalidContentType = errors.Sentinel("invalid avatar content type")
const ErrFileTooLarge = errors.Sentinel("file to be converted exceeds maximum size")
// ConvertAvatar parses an avatar from a data URI, converts it to WebP and JPEG, and returns the results.
func (db *DB) ConvertAvatar(data string) (
@ -27,8 +30,6 @@ func (db *DB) ConvertAvatar(data string) (
jpgOut *bytes.Buffer,
err error,
) {
defer vips.ShutdownThread()
data = strings.TrimSpace(data)
if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") {
return nil, nil, ErrInvalidDataURI
@ -40,31 +41,28 @@ func (db *DB) ConvertAvatar(data string) (
return nil, nil, errors.Wrap(err, "invalid base64 data")
}
image, err := vips.LoadImageFromBuffer(rawData, nil)
img, _, err := image.Decode(bytes.NewReader(rawData))
if err != nil {
return nil, nil, errors.Wrap(err, "decoding image")
return nil, nil, errors.Wrap(err, "decodign image")
}
err = image.ThumbnailWithSize(512, 512, vips.InterestingCentre, vips.SizeBoth)
resized := imaging.Fill(img, 512, 512, imaging.Center, imaging.Linear)
webpOut = new(bytes.Buffer)
err = webp.Encode(webpOut, resized, &webp.Options{
Quality: 90,
})
if err != nil {
return nil, nil, errors.Wrap(err, "resizing image")
return nil, nil, errors.Wrap(err, "encoding WebP image")
}
webpExport := vips.NewWebpExportParams()
webpExport.Quality = 90
webpB, _, err := image.ExportWebp(webpExport)
jpgOut = new(bytes.Buffer)
err = jpeg.Encode(jpgOut, resized, &jpeg.Options{
Quality: 80,
})
if err != nil {
return nil, nil, errors.Wrap(err, "exporting webp image")
return nil, nil, errors.Wrap(err, "encoding JPEG image")
}
webpOut = bytes.NewBuffer(webpB)
jpegExport := vips.NewJpegExportParams()
jpegExport.Quality = 80
jpegB, _, err := image.ExportJpeg(jpegExport)
if err != nil {
return nil, nil, errors.Wrap(err, "exporting jpeg image")
}
jpgOut = bytes.NewBuffer(jpegB)
return webpOut, jpgOut, nil
}
@ -81,17 +79,15 @@ func (db *DB) WriteUserAvatar(ctx context.Context,
}
hash = hex.EncodeToString(hasher.Sum(nil))
_, err = db.minio.PutObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{
_, err = db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{
ContentType: "image/webp",
SendContentMd5: true,
})
if err != nil {
return "", errors.Wrap(err, "uploading webp avatar")
}
_, err = db.minio.PutObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{
_, err = db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{
ContentType: "image/jpeg",
SendContentMd5: true,
})
if err != nil {
return "", errors.Wrap(err, "uploading jpeg avatar")
@ -112,17 +108,15 @@ func (db *DB) WriteMemberAvatar(ctx context.Context,
}
hash = hex.EncodeToString(hasher.Sum(nil))
_, err = db.minio.PutObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{
_, err = db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{
ContentType: "image/webp",
SendContentMd5: true,
})
if err != nil {
return "", errors.Wrap(err, "uploading webp avatar")
}
_, err = db.minio.PutObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{
_, err = db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{
ContentType: "image/jpeg",
SendContentMd5: true,
})
if err != nil {
return "", errors.Wrap(err, "uploading jpeg avatar")
@ -132,12 +126,12 @@ func (db *DB) WriteMemberAvatar(ctx context.Context,
}
func (db *DB) DeleteUserAvatar(ctx context.Context, userID xid.ID, hash string) error {
err := db.minio.RemoveObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{})
err := db.minio.RemoveObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{})
if err != nil {
return errors.Wrap(err, "deleting webp avatar")
}
err = db.minio.RemoveObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{})
err = db.minio.RemoveObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{})
if err != nil {
return errors.Wrap(err, "deleting jpeg avatar")
}
@ -146,12 +140,12 @@ func (db *DB) DeleteUserAvatar(ctx context.Context, userID xid.ID, hash string)
}
func (db *DB) DeleteMemberAvatar(ctx context.Context, memberID xid.ID, hash string) error {
err := db.minio.RemoveObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{})
err := db.minio.RemoveObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{})
if err != nil {
return errors.Wrap(err, "deleting webp avatar")
}
err = db.minio.RemoveObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{})
err = db.minio.RemoveObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{})
if err != nil {
return errors.Wrap(err, "deleting jpeg avatar")
}
@ -160,7 +154,7 @@ func (db *DB) DeleteMemberAvatar(ctx context.Context, memberID xid.ID, hash stri
}
func (db *DB) UserAvatar(ctx context.Context, userID xid.ID, hash string) (io.ReadCloser, error) {
obj, err := db.minio.GetObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", minio.GetObjectOptions{})
obj, err := db.minio.GetObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".webp", minio.GetObjectOptions{})
if err != nil {
return nil, errors.Wrap(err, "getting object")
}
@ -168,7 +162,7 @@ func (db *DB) UserAvatar(ctx context.Context, userID xid.ID, hash string) (io.Re
}
func (db *DB) MemberAvatar(ctx context.Context, memberID xid.ID, hash string) (io.ReadCloser, error) {
obj, err := db.minio.GetObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", minio.GetObjectOptions{})
obj, err := db.minio.GetObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".webp", minio.GetObjectOptions{})
if err != nil {
return nil, errors.Wrap(err, "getting object")
}

View file

@ -6,9 +6,8 @@ import (
"fmt"
"net/url"
"os"
"sync"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/Masterminds/squirrel"
"github.com/jackc/pgx/v5/pgconn"
@ -23,11 +22,6 @@ var sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
const ErrNothingToUpdate = errors.Sentinel("nothing to update")
const (
uniqueViolation = "23505"
foreignKeyViolation = "23503"
)
type Execer interface {
Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error)
}
@ -42,10 +36,6 @@ type DB struct {
baseURL *url.URL
TotalRequests prometheus.Counter
activeUsersDay, activeUsersWeek, activeUsersMonth int64
usersTotal, membersTotal int64
countMu sync.RWMutex
}
func New() (*DB, error) {

View file

@ -7,6 +7,15 @@ import (
type WordStatus string
const (
StatusUnknown WordStatus = ""
StatusFavourite WordStatus = "favourite"
StatusOkay WordStatus = "okay"
StatusJokingly WordStatus = "jokingly"
StatusFriendsOnly WordStatus = "friends_only"
StatusAvoid WordStatus = "avoid"
)
func (w *WordStatus) UnmarshalJSON(src []byte) error {
if string(src) == "null" {
return nil
@ -32,7 +41,7 @@ func (w *WordStatus) UnmarshalJSON(src []byte) error {
}
func (w WordStatus) Valid(extra CustomPreferences) bool {
if w == "favourite" || w == "okay" || w == "jokingly" || w == "friends_only" || w == "avoid" {
if w == StatusFavourite || w == StatusOkay || w == StatusJokingly || w == StatusFriendsOnly || w == StatusAvoid {
return true
}

View file

@ -20,7 +20,7 @@ type DataExport struct {
}
func (de DataExport) Path() string {
return "exports/" + de.UserID.String() + "/" + de.Filename + ".zip"
return "/exports/" + de.UserID.String() + "/" + de.Filename + ".zip"
}
const ErrNoExport = errors.Sentinel("no data export exists")
@ -68,7 +68,6 @@ func (db *DB) CreateExport(ctx context.Context, userID xid.ID, filename string,
_, err = db.minio.PutObject(ctx, db.minioBucket, de.Path(), file, int64(file.Len()), minio.PutObjectOptions{
ContentType: "application/zip",
SendContentMd5: true,
})
if err != nil {
return de, errors.Wrap(err, "writing export file")
@ -79,7 +78,7 @@ func (db *DB) CreateExport(ctx context.Context, userID xid.ID, filename string,
return de, errors.Wrap(err, "building query")
}
err = pgxscan.Get(ctx, db, &de, sql, args...)
pgxscan.Get(ctx, db, &de, sql, args...)
if err != nil {
return de, errors.Wrap(err, "executing sql")
}

View file

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

View file

@ -43,10 +43,7 @@ func (f Field) Validate(custom CustomPreferences) string {
}
if !entry.Status.Valid(custom) {
if entry.Status == "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)
return fmt.Sprintf("entries.%d: status is invalid", i)
}
}

View file

@ -1,326 +0,0 @@
package db
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"io"
"strings"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/davidbyttow/govips/v2/vips"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/minio/minio-go/v7"
"github.com/rs/xid"
)
type PrideFlag struct {
ID xid.ID `json:"id"`
SnowflakeID common.FlagID `json:"id_new"`
UserID xid.ID `json:"-"`
Hash string `json:"hash"`
Name string `json:"name"`
Description *string `json:"description"`
}
type UserFlag struct {
ID int64 `json:"-"`
UserID xid.ID `json:"-"`
FlagID xid.ID `json:"id"`
Hash string `json:"hash"`
Name string `json:"name"`
Description *string `json:"description"`
}
type MemberFlag struct {
ID int64 `json:"-"`
MemberID xid.ID `json:"-"`
FlagID xid.ID `json:"id"`
Hash string `json:"hash"`
Name string `json:"name"`
Description *string `json:"description"`
}
const (
MaxPrideFlags = 500
MaxPrideFlagTitleLength = 100
MaxPrideFlagDescLength = 500
)
const (
ErrInvalidFlagID = errors.Sentinel("invalid flag ID")
ErrFlagNotFound = errors.Sentinel("flag not found")
)
func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, err error) {
sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("lower(name)", "id").ToSql()
if err != nil {
return nil, errors.Wrap(err, "building query")
}
err = pgxscan.Select(ctx, db, &fs, sql, args...)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return NotNull(fs), nil
}
func (db *DB) UserFlag(ctx context.Context, flagID xid.ID) (f PrideFlag, err error) {
sql, args, err := sq.Select("*").From("pride_flags").Where("id = ?", flagID).ToSql()
if err != nil {
return f, errors.Wrap(err, "building query")
}
err = pgxscan.Get(ctx, db, &f, sql, args...)
if err != nil {
if errors.Cause(err) == pgx.ErrNoRows {
return f, ErrFlagNotFound
}
return f, errors.Wrap(err, "executing query")
}
return f, nil
}
func (db *DB) UserFlags(ctx context.Context, userID xid.ID) (fs []UserFlag, err error) {
sql, args, err := sq.Select("u.id", "u.flag_id", "f.user_id", "f.hash", "f.name", "f.description").
From("user_flags AS u").
Where("u.user_id = $1", userID).
Join("pride_flags AS f ON u.flag_id = f.id").
OrderBy("u.id ASC").
ToSql()
if err != nil {
return nil, errors.Wrap(err, "building query")
}
err = pgxscan.Select(ctx, db, &fs, sql, args...)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return NotNull(fs), nil
}
func (db *DB) MemberFlags(ctx context.Context, memberID xid.ID) (fs []MemberFlag, err error) {
sql, args, err := sq.Select("m.id", "m.flag_id", "m.member_id", "f.hash", "f.name", "f.description").
From("member_flags AS m").
Where("m.member_id = $1", memberID).
Join("pride_flags AS f ON m.flag_id = f.id").
OrderBy("m.id ASC").
ToSql()
if err != nil {
return nil, errors.Wrap(err, "building query")
}
err = pgxscan.Select(ctx, db, &fs, sql, args...)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return NotNull(fs), nil
}
func (db *DB) SetUserFlags(ctx context.Context, tx pgx.Tx, userID xid.ID, flags []xid.ID) (err error) {
sql, args, err := sq.Delete("user_flags").Where("user_id = ?", userID).ToSql()
if err != nil {
return errors.Wrap(err, "building sql")
}
_, err = tx.Exec(ctx, sql, args...)
if err != nil {
return errors.Wrap(err, "deleting existing flags")
}
n, err := tx.CopyFrom(ctx, pgx.Identifier{"user_flags"}, []string{"user_id", "flag_id"},
pgx.CopyFromSlice(len(flags), func(i int) ([]any, error) {
return []any{userID, flags[i]}, nil
}))
if err != nil {
pge := &pgconn.PgError{}
if errors.As(err, &pge) {
if pge.Code == foreignKeyViolation {
return ErrInvalidFlagID
}
}
return errors.Wrap(err, "copying new flags")
}
if n > 0 {
log.Debugf("set %v flags for user %v", n, userID)
}
return nil
}
func (db *DB) SetMemberFlags(ctx context.Context, tx pgx.Tx, memberID xid.ID, flags []xid.ID) (err error) {
sql, args, err := sq.Delete("member_flags").Where("member_id = ?", memberID).ToSql()
if err != nil {
return errors.Wrap(err, "building sql")
}
_, err = tx.Exec(ctx, sql, args...)
if err != nil {
return errors.Wrap(err, "deleting existing flags")
}
n, err := tx.CopyFrom(ctx, pgx.Identifier{"member_flags"}, []string{"member_id", "flag_id"},
pgx.CopyFromSlice(len(flags), func(i int) ([]any, error) {
return []any{memberID, flags[i]}, nil
}))
if err != nil {
pge := &pgconn.PgError{}
if errors.As(err, &pge) {
if pge.Code == foreignKeyViolation {
return ErrInvalidFlagID
}
}
return errors.Wrap(err, "copying new flags")
}
if n > 0 {
log.Debugf("set %v flags for member %v", n, memberID)
}
return nil
}
func (db *DB) CreateFlag(ctx context.Context, tx pgx.Tx, userID xid.ID, name, desc string) (f PrideFlag, err error) {
description := &desc
if desc == "" {
description = nil
}
sql, args, err := sq.Insert("pride_flags").
SetMap(map[string]any{
"id": xid.New(),
"snowflake_id": common.GenerateID(),
"hash": "",
"user_id": userID.String(),
"name": name,
"description": description,
}).Suffix("RETURNING *").ToSql()
if err != nil {
return f, errors.Wrap(err, "building query")
}
err = pgxscan.Get(ctx, tx, &f, sql, args...)
if err != nil {
return f, errors.Wrap(err, "executing query")
}
return f, nil
}
func (db *DB) EditFlag(ctx context.Context, tx pgx.Tx, flagID xid.ID, name, desc, hash *string) (f PrideFlag, err error) {
b := sq.Update("pride_flags").
Where("id = ?", flagID)
if name != nil {
b = b.Set("name", *name)
}
if desc != nil {
if *desc == "" {
b = b.Set("description", nil)
} else {
b = b.Set("description", *desc)
}
}
if hash != nil {
b = b.Set("hash", *hash)
}
sql, args, err := b.Suffix("RETURNING *").ToSql()
if err != nil {
return f, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, tx, &f, sql, args...)
if err != nil {
return f, errors.Wrap(err, "executing query")
}
return f, nil
}
func (db *DB) WriteFlag(ctx context.Context, flagID xid.ID, flag *bytes.Buffer) (hash string, err error) {
hasher := sha256.New()
_, err = hasher.Write(flag.Bytes())
if err != nil {
return "", errors.Wrap(err, "hashing flag")
}
hash = hex.EncodeToString(hasher.Sum(nil))
_, err = db.minio.PutObject(ctx, db.minioBucket, "flags/"+hash+".webp", flag, -1, minio.PutObjectOptions{
ContentType: "image/webp",
SendContentMd5: true,
})
if err != nil {
return "", errors.Wrap(err, "uploading flag")
}
return hash, nil
}
func (db *DB) DeleteFlag(ctx context.Context, flagID xid.ID, hash string) error {
sql, args, err := sq.Delete("pride_flags").Where("id = ?", flagID).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
}
func (db *DB) FlagObject(ctx context.Context, flagID xid.ID, hash string) (io.ReadCloser, error) {
obj, err := db.minio.GetObject(ctx, db.minioBucket, "/flags/"+flagID.String()+"/"+hash+".webp", minio.GetObjectOptions{})
if err != nil {
return nil, errors.Wrap(err, "getting object")
}
return obj, nil
}
const MaxFlagInputSize = 512_000
// ConvertFlag parses a flag from a data URI, converts it to WebP, and returns the result.
func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) {
defer vips.ShutdownThread()
data = strings.TrimSpace(data)
if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") {
return nil, ErrInvalidDataURI
}
split := strings.Split(data, ",")
rawData, err := base64.StdEncoding.DecodeString(split[1])
if err != nil {
return nil, errors.Wrap(err, "invalid base64 data")
}
if len(rawData) > MaxFlagInputSize {
return nil, ErrFileTooLarge
}
image, err := vips.LoadImageFromBuffer(rawData, nil)
if err != nil {
return nil, errors.Wrap(err, "decoding image")
}
err = image.ThumbnailWithSize(256, 256, vips.InterestingNone, vips.SizeBoth)
if err != nil {
return nil, errors.Wrap(err, "resizing image")
}
webpExport := vips.NewWebpExportParams()
webpExport.Lossless = true
webpB, _, err := image.ExportWebp(webpExport)
if err != nil {
return nil, errors.Wrap(err, "exporting webp image")
}
webpOut = bytes.NewBuffer(webpB)
return webpOut, nil
}

View file

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

View file

@ -3,13 +3,8 @@ package db
import (
"context"
"regexp"
"strings"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/Masterminds/squirrel"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
@ -24,8 +19,6 @@ const (
type Member struct {
ID xid.ID
UserID xid.ID
SnowflakeID common.MemberID
SID string `db:"sid"`
Name string
DisplayName *string
Bio *string
@ -42,24 +35,9 @@ const (
)
// member names must match this regex
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",
}
var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"'$%&()+<=>^|~`,]{1,100}$")
func MemberNameValid(name string) bool {
for i := range invalidMemberNames {
if strings.EqualFold(name, invalidMemberNames[i]) {
return false
}
}
return memberNameRegex.MatchString(name)
}
@ -76,23 +54,9 @@ func (db *DB) Member(ctx context.Context, id xid.ID) (m Member, err error) {
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.
func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (m Member, err error) {
sf, _ := common.ParseSnowflake(memberRef) // error can be ignored as the zero value will never be used as an ID
sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).Where("(id = ? or snowflake_id = ? or name = ?)", memberRef, sf, memberRef).ToSql()
sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).Where("(id = ? or name = ?)", memberRef, memberRef).ToSql()
if err != nil {
return m, errors.Wrap(err, "building sql")
}
@ -104,25 +68,6 @@ func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (
return m, nil
}
// MemberBySID gets a user by their short ID.
func (db *DB) MemberBySID(ctx context.Context, sid string) (u Member, err error) {
sql, args, err := sq.Select("*").From("members").Where("sid = ?", sid).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, ErrMemberNotFound
}
return u, errors.Wrap(err, "getting members from db")
}
return u, nil
}
// UserMembers returns all of a user's members, sorted by name.
func (db *DB) UserMembers(ctx context.Context, userID xid.ID, showHidden bool) (ms []Member, err error) {
builder := sq.Select("*").
@ -154,8 +99,8 @@ func (db *DB) CreateMember(
name string, displayName *string, bio string, links []string,
) (m Member, err error) {
sql, args, err := sq.Insert("members").
Columns("user_id", "snowflake_id", "id", "sid", "name", "display_name", "bio", "links").
Values(userID, common.GenerateID(), xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links).
Columns("user_id", "id", "name", "display_name", "bio", "links").
Values(userID, xid.New(), name, displayName, bio, links).
Suffix("RETURNING *").ToSql()
if err != nil {
return m, errors.Wrap(err, "building sql")
@ -166,7 +111,7 @@ func (db *DB) CreateMember(
pge := &pgconn.PgError{}
if errors.As(err, &pge) {
// unique constraint violation
if pge.Code == uniqueViolation {
if pge.Code == "23505" {
return m, ErrMemberNameInUse
}
}
@ -273,7 +218,7 @@ func (db *DB) UpdateMember(
if err != nil {
pge := &pgconn.PgError{}
if errors.As(err, &pge) {
if pge.Code == uniqueViolation {
if pge.Code == "23505" {
return m, ErrMemberNameInUse
}
}
@ -282,48 +227,3 @@ func (db *DB) UpdateMember(
}
return m, nil
}
func (db *DB) RerollMemberSID(ctx context.Context, userID, memberID xid.ID) (newID string, err error) {
tx, err := db.Begin(ctx)
if err != nil {
return "", errors.Wrap(err, "beginning transaction")
}
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").
Set("sid", squirrel.Expr("find_free_member_sid()")).
Where("id = ?", memberID).
Suffix("RETURNING sid").ToSql()
if err != nil {
return "", errors.Wrap(err, "building sql")
}
err = tx.QueryRow(ctx, sql, args...).Scan(&newID)
if err != nil {
return "", errors.Wrap(err, "executing query")
}
sql, args, err = sq.Update("users").
Set("last_sid_reroll", time.Now()).
Where("id = ?", userID).ToSql()
if err != nil {
return "", errors.Wrap(err, "building sql")
}
_, err = tx.Exec(ctx, sql, args...)
if err != nil {
return "", errors.Wrap(err, "executing query")
}
err = tx.Commit(ctx)
if err != nil {
return "", errors.Wrap(err, "committing transaction")
}
return newID, nil
}

View file

@ -2,14 +2,11 @@ package db
import (
"context"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/jackc/pgx/v5/pgconn"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/rs/xid"
)
func (db *DB) initMetrics() (err error) {
@ -21,11 +18,6 @@ func (db *DB) initMetrics() (err error) {
if err != nil {
log.Errorf("getting user count for metrics: %v", err)
}
db.countMu.Lock()
db.usersTotal = count
db.countMu.Unlock()
return float64(count)
}))
if err != nil {
@ -40,90 +32,12 @@ func (db *DB) initMetrics() (err error) {
if err != nil {
log.Errorf("getting member count for metrics: %v", err)
}
db.countMu.Lock()
db.membersTotal = count
db.countMu.Unlock()
return float64(count)
}))
if err != nil {
return errors.Wrap(err, "registering member count gauge")
}
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "pronouns_users_active",
Help: "The number of users active in the past 30 days",
}, func() float64 {
count, err := db.ActiveUsers(context.Background(), ActiveMonth)
if err != nil {
log.Errorf("getting active user count for metrics: %v", err)
}
db.countMu.Lock()
db.activeUsersMonth = count
db.countMu.Unlock()
return float64(count)
}))
if err != nil {
return errors.Wrap(err, "registering active user count gauge")
}
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "pronouns_users_active_week",
Help: "The number of users active in the past 7 days",
}, func() float64 {
count, err := db.ActiveUsers(context.Background(), ActiveWeek)
if err != nil {
log.Errorf("getting active user count for metrics: %v", err)
}
db.countMu.Lock()
db.activeUsersWeek = count
db.countMu.Unlock()
return float64(count)
}))
if err != nil {
return errors.Wrap(err, "registering active user count gauge")
}
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "pronouns_users_active_day",
Help: "The number of users active in the past 1 day",
}, func() float64 {
count, err := db.ActiveUsers(context.Background(), ActiveDay)
if err != nil {
log.Errorf("getting active user count for metrics: %v", err)
}
db.countMu.Lock()
db.activeUsersDay = count
db.countMu.Unlock()
return float64(count)
}))
if err != nil {
return errors.Wrap(err, "registering active user count gauge")
}
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "pronouns_database_latency",
Help: "The latency to the database in nanoseconds",
}, func() float64 {
start := time.Now()
_, err = db.Exec(context.Background(), "SELECT 1")
if err != nil {
log.Errorf("pinging database: %v", err)
return -1
}
return float64(time.Since(start))
}))
if err != nil {
return errors.Wrap(err, "registering database latency gauge")
}
db.TotalRequests = promauto.NewCounter(prometheus.CounterOpts{
Name: "pronouns_api_requests_total",
Help: "The total number of API requests since the last restart",
@ -132,22 +46,6 @@ func (db *DB) initMetrics() (err error) {
return nil
}
func (db *DB) Counts(ctx context.Context) (numUsers, numMembers, usersDay, usersWeek, usersMonth int64) {
db.countMu.Lock()
if db.usersTotal != 0 {
defer db.countMu.Unlock()
return db.usersTotal, db.membersTotal, db.activeUsersDay, db.activeUsersWeek, db.activeUsersMonth
}
db.countMu.Unlock()
numUsers, _ = db.TotalUserCount(ctx)
numMembers, _ = db.TotalMemberCount(ctx)
usersDay, _ = db.ActiveUsers(ctx, ActiveDay)
usersWeek, _ = db.ActiveUsers(ctx, ActiveWeek)
usersMonth, _ = db.ActiveUsers(ctx, ActiveMonth)
return numUsers, numMembers, usersDay, usersWeek, usersMonth
}
func (db *DB) TotalUserCount(ctx context.Context) (numUsers int64, err error) {
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL").Scan(&numUsers)
if err != nil {
@ -163,36 +61,3 @@ func (db *DB) TotalMemberCount(ctx context.Context) (numMembers int64, err error
}
return numMembers, nil
}
const (
ActiveMonth = 30 * 24 * time.Hour
ActiveWeek = 7 * 24 * time.Hour
ActiveDay = 24 * time.Hour
)
func (db *DB) ActiveUsers(ctx context.Context, dur time.Duration) (numUsers int64, err error) {
t := time.Now().Add(-dur)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL AND last_active > $1", t).Scan(&numUsers)
if err != nil {
return 0, errors.Wrap(err, "querying active user count")
}
return numUsers, nil
}
type connOrTx interface {
Exec(ctx context.Context, sql string, arguments ...any) (commandTag pgconn.CommandTag, err error)
}
// UpdateActiveTime is called on create and update endpoints (PATCH /users/@me, POST/PATCH/DELETE /members)
func (db *DB) UpdateActiveTime(ctx context.Context, tx connOrTx, userID xid.ID) (err error) {
sql, args, err := sq.Update("users").Set("last_active", time.Now().UTC()).Where("id = ?", userID).ToSql()
if err != nil {
return errors.Wrap(err, "building sql")
}
_, err = tx.Exec(ctx, sql, args...)
if err != nil {
return errors.Wrap(err, "executing query")
}
return nil
}

View file

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

View file

@ -59,13 +59,7 @@ func (db *DB) Reports(ctx context.Context, closed bool, before int) (rs []Report
}
func (db *DB) ReportsByUser(ctx context.Context, userID xid.ID, before int) (rs []Report, err error) {
builder := sq.Select("*",
"(SELECT username FROM users WHERE id = reports.user_id) AS user_name",
"(SELECT name FROM members WHERE id = reports.member_id) AS member_name").
From("reports").
Where("user_id = ?", userID).
Limit(ReportPageSize).
OrderBy("id DESC")
builder := sq.Select("*").From("reports").Where("user_id = ?", userID).Limit(ReportPageSize).OrderBy("id DESC")
if before != 0 {
builder = builder.Where("id < ?", before)
}
@ -85,13 +79,7 @@ func (db *DB) ReportsByUser(ctx context.Context, userID xid.ID, before int) (rs
}
func (db *DB) ReportsByReporter(ctx context.Context, reporterID xid.ID, before int) (rs []Report, err error) {
builder := sq.Select("*",
"(SELECT username FROM users WHERE id = reports.user_id) AS user_name",
"(SELECT name FROM members WHERE id = reports.member_id) AS member_name").
From("reports").
Where("reporter_id = ?", reporterID).
Limit(ReportPageSize).
OrderBy("id DESC")
builder := sq.Select("*").From("reports").Where("reporter_id = ?", reporterID).Limit(ReportPageSize).OrderBy("id DESC")
if before != 0 {
builder = builder.Where("id < ?", before)
}

View file

@ -6,13 +6,11 @@ import (
"encoding/hex"
"fmt"
"regexp"
"strings"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/icons"
"codeberg.org/u1f320/pronouns.cc/backend/common"
"codeberg.org/u1f320/pronouns.cc/backend/icons"
"emperror.dev/errors"
"github.com/Masterminds/squirrel"
"github.com/bwmarrin/discordgo"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
@ -22,13 +20,10 @@ import (
type User struct {
ID xid.ID
SnowflakeID common.UserID
SID string `db:"sid"`
Username string
DisplayName *string
Bio *string
MemberTitle *string
LastActive time.Time
Avatar *string
Links []string
@ -53,9 +48,6 @@ type User struct {
MaxInvites int
IsAdmin bool
ListPrivate bool
LastSIDReroll time.Time `db:"last_sid_reroll"`
Timezone *string
Settings UserSettings
DeletedAt *time.Time
SelfDelete *bool
@ -117,65 +109,9 @@ func (u User) NumProviders() (numProviders int) {
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
const (
BadgeAdmin Badge = 1 << 0
)
// usernames must match this regex
var usernameRegex = regexp.MustCompile(`^[\w-.]{2,40}$`)
// List of usernames that cannot be used, because they could create confusion, conflict with other pages, or cause bugs.
var invalidUsernames = []string{
"..",
"admin",
"administrator",
"mod",
"moderator",
"api",
"page",
"pronouns",
"settings",
"pronouns.cc",
"pronounscc",
}
func UsernameValid(username string) (err error) {
if !usernameRegex.MatchString(username) {
if len(username) < 2 {
return ErrUsernameTooShort
} else if len(username) > 40 {
return ErrUsernameTooLong
}
return ErrInvalidUsername
}
for i := range invalidUsernames {
if strings.EqualFold(username, invalidUsernames[i]) {
return ErrBannedUsername
}
}
return nil
}
const (
ErrUserNotFound = errors.Sentinel("user not found")
@ -183,7 +119,6 @@ const (
ErrInvalidUsername = errors.Sentinel("username contains invalid characters")
ErrUsernameTooShort = errors.Sentinel("username is too short")
ErrUsernameTooLong = errors.Sentinel("username is too long")
ErrBannedUsername = errors.Sentinel("username is banned")
)
const (
@ -203,11 +138,17 @@ const (
func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u User, err error) {
// check if the username is valid
// if not, return an error depending on what failed
if err := UsernameValid(username); err != nil {
return u, err
if !usernameRegex.MatchString(username) {
if len(username) < 2 {
return u, ErrUsernameTooShort
} else if len(username) > 40 {
return u, ErrUsernameTooLong
}
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()
return u, ErrInvalidUsername
}
sql, args, err := sq.Insert("users").Columns("id", "username").Values(xid.New(), username).Suffix("RETURNING *").ToSql()
if err != nil {
return u, errors.Wrap(err, "building sql")
}
@ -217,7 +158,7 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use
pge := &pgconn.PgError{}
if errors.As(err, &pge) {
// unique constraint violation
if pge.Code == uniqueViolation {
if pge.Code == "23505" {
return u, ErrUsernameTaken
}
}
@ -495,26 +436,6 @@ func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) {
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.
func (db *DB) Username(ctx context.Context, name string) (u User, err error) {
sql, args, err := sq.Select("*").From("users").Where("username = ?", name).ToSql()
@ -534,28 +455,9 @@ func (db *DB) Username(ctx context.Context, name string) (u User, err error) {
return u, nil
}
// UserBySID gets a user by their short ID.
func (db *DB) UserBySID(ctx context.Context, sid string) (u User, err error) {
sql, args, err := sq.Select("*").From("users").Where("sid = ?", sid).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
}
// UsernameTaken checks if the given username is already taken.
func (db *DB) UsernameTaken(ctx context.Context, username string) (valid, taken bool, err error) {
if err := UsernameValid(username); err != nil {
if !usernameRegex.MatchString(username) {
return false, false, nil
}
@ -565,8 +467,8 @@ func (db *DB) UsernameTaken(ctx context.Context, username string) (valid, taken
// UpdateUsername validates the given username, then updates the given user's name to it if valid.
func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName string) error {
if err := UsernameValid(newName); err != nil {
return err
if !usernameRegex.MatchString(newName) {
return ErrInvalidUsername
}
sql, args, err := sq.Update("users").Set("username", newName).Where("id = ?", id).ToSql()
@ -579,7 +481,7 @@ func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName
pge := &pgconn.PgError{}
if errors.As(err, &pge) {
// unique constraint violation
if pge.Code == uniqueViolation {
if pge.Code == "23505" {
return ErrUsernameTaken
}
}
@ -596,10 +498,9 @@ func (db *DB) UpdateUser(
memberTitle *string, listPrivate *bool,
links *[]string,
avatar *string,
timezone *string,
customPreferences *CustomPreferences,
) (u User, err error) {
if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil && timezone == nil && customPreferences == nil {
if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil && customPreferences == nil {
sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql()
if err != nil {
return u, errors.Wrap(err, "building sql")
@ -635,13 +536,6 @@ func (db *DB) UpdateUser(
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 {
builder = builder.Set("links", *links)
}
@ -689,23 +583,6 @@ func (db *DB) DeleteUser(ctx context.Context, tx pgx.Tx, id xid.ID, selfDelete b
return nil
}
func (db *DB) RerollUserSID(ctx context.Context, id xid.ID) (newID string, err error) {
sql, args, err := sq.Update("users").
Set("sid", squirrel.Expr("find_free_user_sid()")).
Set("last_sid_reroll", time.Now()).
Where("id = ?", id).
Suffix("RETURNING sid").ToSql()
if err != nil {
return "", errors.Wrap(err, "building sql")
}
err = db.QueryRow(ctx, sql, args...).Scan(&newID)
if err != nil {
return "", errors.Wrap(err, "executing query")
}
return newID, nil
}
func (db *DB) UndoDeleteUser(ctx context.Context, id xid.ID) error {
sql, args, err := sq.Update("users").
Set("deleted_at", nil).
@ -825,24 +702,3 @@ func (db *DB) CleanUser(ctx context.Context, id xid.ID) error {
}
return nil
}
const inactiveUsersSQL = `select id, snowflake_id from users
where last_active < now() - '30 days'::interval
and display_name is null and bio is null and timezone is null
and links is null and avatar is null and member_title is null
and names = '[]' and pronouns = '[]'
and (select count(m.id) from members m where user_id = users.id) = 0
and (select count(f.id) from user_fields f where user_id = users.id) = 0;`
// InactiveUsers gets the list of inactive users from the database.
// "Inactive" is defined as:
// - not logged in for 30 days or more
// - no display name, bio, avatar, names, pronouns, profile links, or profile fields
// - no members
func (db *DB) InactiveUsers(ctx context.Context, tx pgx.Tx) (us []User, err error) {
err = pgxscan.Select(ctx, tx, &us, inactiveUsersSQL)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return us, nil
}

View file

@ -1,27 +0,0 @@
package db
import (
"context"
"emperror.dev/errors"
"github.com/rs/xid"
)
type UserSettings struct {
ReadChangelog string `json:"read_changelog"`
ReadSettingsNotice string `json:"read_settings_notice"`
ReadGlobalNotice int `json:"read_global_notice"`
}
func (db *DB) UpdateUserSettings(ctx context.Context, id xid.ID, us UserSettings) error {
sql, args, err := sq.Update("users").Set("settings", us).Where("id = ?", id).ToSql()
if err != nil {
return errors.Wrap(err, "building sql")
}
_, err = db.Exec(ctx, sql, args...)
if err != nil {
return errors.Wrap(err, "executing query")
}
return nil
}

View file

@ -13,8 +13,8 @@ import (
"os/signal"
"sync"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/xid"

View file

@ -1,7 +1,7 @@
package exporter
import (
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"github.com/rs/xid"
)

File diff suppressed because it is too large Load diff

View file

@ -7,11 +7,9 @@ import (
"os"
"os/signal"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"github.com/davidbyttow/govips/v2/vips"
"github.com/getsentry/sentry-go"
"github.com/go-chi/render"
_ "github.com/joho/godotenv/autoload"
"github.com/urfave/cli/v2"
@ -24,25 +22,6 @@ var Command = &cli.Command{
}
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
vips.LoggingSettings(nil, vips.LogLevelWarning)
vips.Startup(nil)
defer vips.Shutdown()
port := ":" + os.Getenv("PORT")
s, err := server.New()
@ -76,8 +55,9 @@ func run(c *cli.Context) error {
return nil
case err := <-e:
log.Fatalf("Error running server: %v", err)
return err
}
return nil
}
const MaxContentLength = 2 * 1024 * 1024

View file

@ -1,99 +0,0 @@
package prns
import (
"context"
"net/http"
"os"
"os/signal"
"strings"
dbpkg "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"github.com/urfave/cli/v2"
)
var Command = &cli.Command{
Name: "shortener",
Usage: "URL shortener service",
Action: run,
}
func run(c *cli.Context) error {
port := ":" + os.Getenv("PRNS_PORT")
baseURL := os.Getenv("BASE_URL")
db, err := dbpkg.New()
if err != nil {
log.Fatalf("creating database: %v", err)
return err
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
log.Errorf("recovered from panic: %v", err)
}
}()
id := strings.TrimPrefix(r.URL.Path, "/")
if len(id) == 5 {
u, err := db.UserBySID(r.Context(), id)
if err != nil {
if err != dbpkg.ErrUserNotFound {
log.Errorf("getting user: %v", err)
}
http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
return
}
http.Redirect(w, r, baseURL+"/@"+u.Username, http.StatusTemporaryRedirect)
return
}
if len(id) == 6 {
m, err := db.MemberBySID(r.Context(), id)
if err != nil {
if err != dbpkg.ErrMemberNotFound {
log.Errorf("getting member: %v", err)
}
http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
return
}
u, err := db.User(r.Context(), m.UserID)
if err != nil {
log.Errorf("getting user for member %v: %v", m.ID, err)
http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
return
}
http.Redirect(w, r, baseURL+"/@"+u.Username+"/"+m.Name, http.StatusTemporaryRedirect)
return
}
http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
})
e := make(chan error)
go func() {
e <- http.ListenAndServe(port, nil)
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
log.Infof("API server running at %v!", port)
select {
case <-ctx.Done():
log.Info("Interrupt signal received, shutting down...")
db.Close()
return nil
case err := <-e:
log.Fatalf("Error running server: %v", err)
}
return nil
}

View file

@ -1,30 +1,26 @@
package backend
import (
"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/v1/meta"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/mod"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/user"
user2 "codeberg.org/pronounscc/pronouns.cc/backend/routes/v2/user"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/routes/auth"
"codeberg.org/u1f320/pronouns.cc/backend/routes/bot"
"codeberg.org/u1f320/pronouns.cc/backend/routes/member"
"codeberg.org/u1f320/pronouns.cc/backend/routes/meta"
"codeberg.org/u1f320/pronouns.cc/backend/routes/mod"
"codeberg.org/u1f320/pronouns.cc/backend/routes/user"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
_ "embed"
)
// mountRoutes mounts all API routes on the server's router.
// they are all mounted under /v1/
func mountRoutes(s *server.Server) {
// future-proofing for API versions
s.Router.Route("/v1", func(r chi.Router) {
auth.Mount(s, r)
user.Mount(s, r)
member.Mount(s, r)
bot.Mount(s, r)
meta.Mount(s, r)
mod.Mount(s, r)
})
s.Router.Route("/v2", func(r chi.Router) {
user2.Mount(s, r)
})
}

View file

@ -5,13 +5,12 @@ import (
"os"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/bwmarrin/discordgo"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
"golang.org/x/oauth2"
@ -43,7 +42,6 @@ type discordCallbackResponse struct {
Discord string `json:"discord,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite"` // require an invite for signing up
RequireCaptcha bool `json:"require_captcha"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
@ -62,7 +60,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
// if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil {
return errors.Wrap(err, "validating state")
return err
}
return server.APIError{Code: server.ErrInvalidState}
@ -80,7 +78,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
dg, _ := discordgo.New(token.Type() + " " + token.AccessToken)
du, err := dg.User("@me")
if err != nil {
return errors.Wrap(err, "getting discord user")
return err
}
u, err := s.DB.DiscordUser(ctx, du.ID)
@ -91,7 +89,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return errors.Wrap(err, "saving undelete token")
return err
}
render.JSON(w, r, discordCallbackResponse{
@ -115,7 +113,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return errors.Wrap(err, "creating token")
return err
}
// save token to database
@ -138,7 +136,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
return nil
} else if err != db.ErrUserNotFound { // internal error
return errors.Wrap(err, "getting user")
return err
}
// no user found, so save a ticket + save their Discord info in Redis
@ -146,7 +144,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600")
if err != nil {
log.Errorf("setting Discord user for ticket %q: %v", ticket, err)
return errors.Wrap(err, "caching discord user for ticket")
return err
}
render.JSON(w, r, discordCallbackResponse{
@ -154,7 +152,6 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
Discord: du.String(),
Ticket: ticket,
RequireInvite: s.RequireInvite,
RequireCaptcha: s.hcaptchaSecret != "",
})
return nil
@ -196,11 +193,6 @@ func (s *Server) discordLink(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket}
}
if du.ID == "" {
log.Errorf("linking user with id %v: discord user ID was empty", claims.UserID)
return server.APIError{Code: server.ErrInternalServerError, Details: "Discord user ID is empty"}
}
err = u.UpdateFromDiscord(ctx, s.DB, du)
if err != nil {
return errors.Wrap(err, "updating user from discord")
@ -257,7 +249,6 @@ type signupRequest struct {
Ticket string `json:"ticket"`
Username string `json:"username"`
InviteCode string `json:"invite_code"`
CaptchaResponse string `json:"captcha_response"`
}
type signupResponse struct {
@ -279,7 +270,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return errors.Wrap(err, "checking if username is taken")
return err
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
@ -292,12 +283,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
defer tx.Rollback(ctx)
du := new(discordgo.User)
err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du)
@ -307,19 +293,6 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket}
}
// check captcha
if s.hcaptchaSecret != "" {
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
if err != nil {
log.Errorf("verifying captcha: %v", err)
return server.APIError{Code: server.ErrInternalServerError}
}
if !ok {
return server.APIError{Code: server.ErrInvalidCaptcha}
}
}
u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
@ -329,11 +302,6 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "creating user")
}
if du.ID == "" {
log.Errorf("creating user with name %q: user ID was empty", req.Username)
return server.APIError{Code: server.ErrInternalServerError, Details: "Discord user ID is empty"}
}
err = u.UpdateFromDiscord(ctx, tx, du)
if err != nil {
return errors.Wrap(err, "updating user from discord")

View file

@ -6,12 +6,11 @@ import (
"net/http"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
)
@ -31,7 +30,6 @@ type fediCallbackResponse struct {
Fediverse string `json:"fediverse,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite"` // require an invite for signing up
RequireCaptcha bool `json:"require_captcha"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
@ -55,7 +53,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
// if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil {
return errors.Wrap(err, "validating state")
return err
}
return server.APIError{Code: server.ErrInvalidState}
@ -112,7 +110,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return errors.Wrap(err, "saving undelete token")
return err
}
render.JSON(w, r, fediCallbackResponse{
@ -136,7 +134,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return errors.Wrap(err, "creating token")
return err
}
// save token to database
@ -159,7 +157,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
return nil
} else if err != db.ErrUserNotFound { // internal error
return errors.Wrap(err, "getting user")
return err
}
// no user found, so save a ticket + save their Mastodon info in Redis
@ -167,7 +165,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600")
if err != nil {
log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err)
return errors.Wrap(err, "setting user for ticket")
return err
}
render.JSON(w, r, fediCallbackResponse{
@ -175,7 +173,6 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
Fediverse: mu.Username,
Ticket: ticket,
RequireInvite: s.RequireInvite,
RequireCaptcha: s.hcaptchaSecret != "",
})
return nil
@ -223,11 +220,6 @@ func (s *Server) mastodonLink(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket}
}
if mu.ID == "" {
log.Errorf("linking user with id %v: user ID was empty", claims.UserID)
return server.APIError{Code: server.ErrInternalServerError, Details: "Mastodon user ID is empty"}
}
err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID)
if err != nil {
return errors.Wrap(err, "updating user from mastoAPI")
@ -285,7 +277,6 @@ type fediSignupRequest struct {
Ticket string `json:"ticket"`
Username string `json:"username"`
InviteCode string `json:"invite_code"`
CaptchaResponse string `json:"captcha_response"`
}
func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
@ -307,7 +298,7 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return errors.Wrap(err, "checking if username is taken")
return err
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
@ -320,12 +311,7 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
defer tx.Rollback(ctx)
mu := new(partialMastodonAccount)
err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu)
@ -335,19 +321,6 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket}
}
// check captcha
if s.hcaptchaSecret != "" {
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
if err != nil {
log.Errorf("verifying captcha: %v", err)
return server.APIError{Code: server.ErrInternalServerError}
}
if !ok {
return server.APIError{Code: server.ErrInvalidCaptcha}
}
}
u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
@ -357,11 +330,6 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "creating user")
}
if mu.ID == "" {
log.Errorf("creating user with name %q: user ID was empty", req.Username)
return server.APIError{Code: server.ErrInternalServerError, Details: "Mastodon user ID is empty"}
}
err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID)
if err != nil {
return errors.Wrap(err, "updating user from mastoAPI")

View file

@ -7,12 +7,11 @@ import (
"io"
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
)
@ -91,7 +90,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return errors.Wrap(err, "saving undelete token")
return err
}
render.JSON(w, r, fediCallbackResponse{
@ -115,7 +114,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return errors.Wrap(err, "creating token")
return err
}
// save token to database
@ -138,7 +137,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
return nil
} else if err != db.ErrUserNotFound { // internal error
return errors.Wrap(err, "getting user")
return err
}
// no user found, so save a ticket + save their Misskey info in Redis
@ -146,7 +145,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu.User, "EX", "600")
if err != nil {
log.Errorf("setting misskey user for ticket %q: %v", ticket, err)
return errors.Wrap(err, "setting user for ticket")
return err
}
render.JSON(w, r, fediCallbackResponse{
@ -154,7 +153,6 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
Fediverse: mu.User.Username,
Ticket: ticket,
RequireInvite: s.RequireInvite,
RequireCaptcha: s.hcaptchaSecret != "",
})
return nil
@ -197,11 +195,6 @@ func (s *Server) misskeyLink(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket}
}
if mu.ID == "" {
log.Errorf("linking user with id %v: user ID was empty", claims.UserID)
return server.APIError{Code: server.ErrInternalServerError, Details: "Misskey user ID is empty"}
}
err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID)
if err != nil {
return errors.Wrap(err, "updating user from misskey")
@ -235,7 +228,7 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return errors.Wrap(err, "checking if username is taken")
return err
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
@ -248,12 +241,7 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
defer tx.Rollback(ctx)
mu := new(partialMisskeyAccount)
err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu)
@ -263,19 +251,6 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket}
}
// check captcha
if s.hcaptchaSecret != "" {
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
if err != nil {
log.Errorf("verifying captcha: %v", err)
return server.APIError{Code: server.ErrInternalServerError}
}
if !ok {
return server.APIError{Code: server.ErrInvalidCaptcha}
}
}
u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
@ -285,11 +260,6 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "creating user")
}
if mu.ID == "" {
log.Errorf("creating user with name %q: user ID was empty", req.Username)
return server.APIError{Code: server.ErrInternalServerError, Details: "Misskey user ID is empty"}
}
err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID)
if err != nil {
return errors.Wrap(err, "updating user from misskey")

View file

@ -6,7 +6,7 @@ import (
"io"
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
)

View file

@ -8,8 +8,8 @@ import (
"net/url"
"strings"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
)
@ -65,15 +65,11 @@ func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r
}
switch softwareName {
case "iceshrimp":
softwareName = "firefish"
fallthrough
case "misskey", "foundkey", "calckey", "firefish", "sharkey":
case "misskey", "foundkey", "calckey":
return s.noAppMisskeyURL(ctx, w, r, softwareName, instance)
case "mastodon", "pleroma", "akkoma", "incestoma", "pixelfed", "gotosocial":
case "glitchcafe", "hometown":
softwareName = "mastodon"
case "mastodon", "pleroma", "akkoma", "pixelfed":
default:
// sorry, misskey :( TODO: support misskey
return server.APIError{Code: server.ErrUnsupportedInstance}
}

View file

@ -5,12 +5,11 @@ import (
"os"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
"golang.org/x/oauth2"
@ -37,7 +36,6 @@ type googleCallbackResponse struct {
Google string `json:"google,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite"` // require an invite for signing up
RequireCaptcha bool `json:"require_captcha"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
@ -61,7 +59,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
// if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil {
return errors.Wrap(err, "validating state")
return err
}
return server.APIError{Code: server.ErrInvalidState}
@ -110,7 +108,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return errors.Wrap(err, "saving undelete token")
return err
}
render.JSON(w, r, googleCallbackResponse{
@ -134,7 +132,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return errors.Wrap(err, "creating token")
return err
}
// save token to database
@ -157,7 +155,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
return nil
} else if err != db.ErrUserNotFound { // internal error
return errors.Wrap(err, "getting user")
return err
}
// no user found, so save a ticket + save their Google info in Redis
@ -165,7 +163,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")
if err != nil {
log.Errorf("setting Google user for ticket %q: %v", ticket, err)
return errors.Wrap(err, "setting user for ticket")
return err
}
render.JSON(w, r, googleCallbackResponse{
@ -173,7 +171,6 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
Google: googleUsername,
Ticket: ticket,
RequireInvite: s.RequireInvite,
RequireCaptcha: s.hcaptchaSecret != "",
})
return nil
@ -211,11 +208,6 @@ func (s *Server) googleLink(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket}
}
if gu.ID == "" {
log.Errorf("linking user with id %v: user ID was empty", claims.UserID)
return server.APIError{Code: server.ErrInternalServerError, Details: "Google user ID is empty"}
}
err = u.UpdateFromGoogle(ctx, s.DB, gu.ID, gu.Email)
if err != nil {
return errors.Wrap(err, "updating user from google")
@ -282,7 +274,7 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return errors.Wrap(err, "checking if username is taken")
return err
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
@ -295,12 +287,7 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
defer tx.Rollback(ctx)
gu := new(partialGoogleUser)
err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu)
@ -310,19 +297,6 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket}
}
// check captcha
if s.hcaptchaSecret != "" {
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
if err != nil {
log.Errorf("verifying captcha: %v", err)
return server.APIError{Code: server.ErrInternalServerError}
}
if !ok {
return server.APIError{Code: server.ErrInvalidCaptcha}
}
}
u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
@ -332,11 +306,6 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "creating user")
}
if gu.ID == "" {
log.Errorf("creating user with name %q: user ID was empty", req.Username)
return server.APIError{Code: server.ErrInternalServerError, Details: "Google user ID is empty"}
}
err = u.UpdateFromGoogle(ctx, tx, gu.ID, gu.Email)
if err != nil {
return errors.Wrap(err, "updating user from google")

View file

@ -4,8 +4,8 @@ import (
"net/http"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
)

View file

@ -4,10 +4,9 @@ import (
"net/http"
"os"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
@ -19,14 +18,10 @@ type Server struct {
RequireInvite bool
BaseURL string
hcaptchaSitekey string
hcaptchaSecret string
}
type userResponse struct {
ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
Username string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
@ -53,7 +48,6 @@ type userResponse struct {
func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
return &userResponse{
ID: u.ID,
SnowflakeID: u.SnowflakeID,
Username: u.Username,
DisplayName: u.DisplayName,
Bio: u.Bio,
@ -79,8 +73,6 @@ func Mount(srv *server.Server, r chi.Router) {
Server: srv,
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
BaseURL: os.Getenv("BASE_URL"),
hcaptchaSitekey: os.Getenv("HCAPTCHA_SITEKEY"),
hcaptchaSecret: os.Getenv("HCAPTCHA_SECRET"),
}
r.Route("/auth", func(r chi.Router) {
@ -185,7 +177,7 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
if googleOAuthConfig.ClientID != "" {
googleCfg := googleOAuthConfig
googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google"
resp.Google = googleCfg.AuthCodeURL(state) + "&prompt=select_account"
resp.Google = googleCfg.AuthCodeURL(state)
}
render.JSON(w, r, resp)

View file

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

View file

@ -7,12 +7,11 @@ import (
"os"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
"golang.org/x/oauth2"
@ -59,7 +58,6 @@ type tumblrCallbackResponse struct {
Tumblr string `json:"tumblr,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite"` // require an invite for signing up
RequireCaptcha bool `json:"require_captcha"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
@ -78,7 +76,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
// if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil {
return errors.Wrap(err, "validating state")
return err
}
return server.APIError{Code: server.ErrInvalidState}
@ -143,7 +141,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return errors.Wrap(err, "saving undelete token")
return err
}
render.JSON(w, r, tumblrCallbackResponse{
@ -167,7 +165,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return errors.Wrap(err, "creating token")
return err
}
// save token to database
@ -190,7 +188,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
return nil
} else if err != db.ErrUserNotFound { // internal error
return errors.Wrap(err, "getting user")
return err
}
// no user found, so save a ticket + save their Tumblr info in Redis
@ -198,7 +196,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")
if err != nil {
log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err)
return errors.Wrap(err, "setting user for ticket")
return err
}
render.JSON(w, r, tumblrCallbackResponse{
@ -206,7 +204,6 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
Tumblr: tumblrName,
Ticket: ticket,
RequireInvite: s.RequireInvite,
RequireCaptcha: s.hcaptchaSecret != "",
})
return nil
@ -244,11 +241,6 @@ func (s *Server) tumblrLink(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket}
}
if tui.ID == "" {
log.Errorf("linking user with id %v: user ID was empty", claims.UserID)
return server.APIError{Code: server.ErrInternalServerError, Details: "Tumblr user ID is empty"}
}
err = u.UpdateFromTumblr(ctx, s.DB, tui.ID, tui.Name)
if err != nil {
return errors.Wrap(err, "updating user from tumblr")
@ -315,7 +307,7 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return errors.Wrap(err, "checking if username is taken")
return err
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
@ -328,12 +320,7 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
defer tx.Rollback(ctx)
tui := new(tumblrUserInfo)
err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui)
@ -343,19 +330,6 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket}
}
// check captcha
if s.hcaptchaSecret != "" {
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
if err != nil {
log.Errorf("verifying captcha: %v", err)
return server.APIError{Code: server.ErrInternalServerError}
}
if !ok {
return server.APIError{Code: server.ErrInvalidCaptcha}
}
}
u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
@ -365,11 +339,6 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "creating user")
}
if tui.ID == "" {
log.Errorf("creating user with name %q: user ID was empty", req.Username)
return server.APIError{Code: server.ErrInternalServerError, Details: "Tumblr user ID is empty"}
}
err = u.UpdateFromTumblr(ctx, tx, tui.ID, tui.Name)
if err != nil {
return errors.Wrap(err, "updating user from tumblr")

View file

@ -6,8 +6,8 @@ import (
"encoding/base64"
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/mediocregopher/radix/v4"

183
backend/routes/bot/bot.go Normal file
View file

@ -0,0 +1,183 @@
package bot
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/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 == db.StatusFavourite {
favs = append(favs, e)
}
}
if len(favs) == 0 {
continue
}
var value string
for _, fav := range favs {
if len(fav.Value) > 500 {
break
}
value += fav.Value + "\n"
}
e.Fields = append(e.Fields, &discordgo.MessageEmbedField{
Name: field.Name,
Value: value,
Inline: true,
})
}
respond(w, r, e)
}
func respond(w http.ResponseWriter, r *http.Request, embeds ...*discordgo.MessageEmbed) {
render.JSON(w, r, discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Embeds: embeds,
Flags: discordgo.MessageFlagsEphemeral,
},
})
}

View file

@ -5,13 +5,12 @@ import (
"net/http"
"strings"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/common"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
)
type CreateMemberRequest struct {
@ -81,7 +80,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
if !db.MemberNameValid(cmr.Name) {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Member name cannot contain any of the following: @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , and cannot be one or two periods.",
Details: "Member name cannot contain any of the following: @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, ,",
}
}
@ -120,12 +119,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
if err != nil {
return errors.Wrap(err, "starting transaction")
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
defer tx.Rollback(ctx)
m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links)
if err != nil {
@ -133,14 +127,14 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
return server.APIError{Code: server.ErrMemberNameInUse}
}
return errors.Wrap(err, "creating member")
return err
}
// set names, pronouns, fields
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns))
if err != nil {
log.Errorf("setting names and pronouns for member %v: %v", m.ID, err)
return errors.Wrap(err, "setting names/pronouns")
return err
}
m.Names = cmr.Names
m.Pronouns = cmr.Pronouns
@ -148,7 +142,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields)
if err != nil {
log.Errorf("setting fields for member %v: %v", m.ID, err)
return errors.Wrap(err, "setting fields")
return err
}
if cmr.Avatar != "" {
@ -167,13 +161,13 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
}
log.Errorf("converting member avatar: %v", err)
return errors.Wrap(err, "converting avatar")
return err
}
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
if err != nil {
log.Errorf("uploading member avatar: %v", err)
return errors.Wrap(err, "uploading avatar")
return err
}
err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar)
@ -182,19 +176,12 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
}
}
// update last active time
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "updating last active time")
}
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields, nil, true))
render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields, true))
return nil
}

View file

@ -0,0 +1,56 @@
package member
import (
"net/http"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/server"
)
func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
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}
}
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 {
return server.APIError{Code: server.ErrNotOwnMember}
}
err = s.DB.DeleteMember(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "deleting member")
}
if m.Avatar != nil {
err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar)
if err != nil {
return errors.Wrap(err, "deleting member avatar")
}
}
render.NoContent(w, r)
return nil
}

View file

@ -0,0 +1,152 @@
package member
import (
"context"
"net/http"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
)
type GetMemberResponse struct {
ID xid.ID `json:"id"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
Avatar *string `json:"avatar"`
Links []string `json:"links"`
Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"`
Fields []db.Field `json:"fields"`
User PartialUser `json:"user"`
Unlisted *bool `json:"unlisted,omitempty"`
}
func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember bool) GetMemberResponse {
r := GetMemberResponse{
ID: m.ID,
Name: m.Name,
DisplayName: m.DisplayName,
Bio: m.Bio,
Avatar: m.Avatar,
Links: db.NotNull(m.Links),
Names: db.NotNull(m.Names),
Pronouns: db.NotNull(m.Pronouns),
Fields: db.NotNull(fields),
User: PartialUser{
ID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Avatar: u.Avatar,
CustomPreferences: u.CustomPreferences,
},
}
if isOwnMember {
r.Unlisted = &m.Unlisted
}
return r
}
type PartialUser struct {
ID xid.ID `json:"id"`
Username string `json:"name"`
DisplayName *string `json:"display_name"`
Avatar *string `json:"avatar"`
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
}
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
m, err := s.DB.Member(ctx, id)
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
u, err := s.DB.User(ctx, m.UserID)
if err != nil {
return err
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
isOwnMember := false
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
isOwnMember = true
}
fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil {
return err
}
render.JSON(w, r, dbMemberToMember(u, m, fields, isOwnMember))
return nil
}
func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
u, err := s.parseUser(ctx, chi.URLParam(r, "userRef"))
if err != nil {
return server.APIError{
Code: server.ErrUserNotFound,
}
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
isOwnMember := false
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
isOwnMember = true
}
m, err := s.DB.UserMember(ctx, u.ID, chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil {
return err
}
render.JSON(w, r, dbMemberToMember(u, m, fields, isOwnMember))
return nil
}
func (s *Server) parseUser(ctx context.Context, userRef string) (u db.User, err error) {
if id, err := xid.FromString(userRef); err != nil {
u, err := s.DB.User(ctx, id)
if err == nil {
return u, nil
}
}
return s.DB.Username(ctx, userRef)
}

View file

@ -3,10 +3,8 @@ package member
import (
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
@ -14,8 +12,6 @@ import (
type memberListResponse struct {
ID xid.ID `json:"id"`
SnowflakeID common.MemberID `json:"id_new"`
SID string `json:"sid"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
@ -31,10 +27,7 @@ func membersToMemberList(ms []db.Member, isSelf bool) []memberListResponse {
for i := range ms {
resps[i] = memberListResponse{
ID: ms[i].ID,
SnowflakeID: ms[i].SnowflakeID,
SID: ms[i].SID,
Name: ms[i].Name,
DisplayName: ms[i].DisplayName,
Bio: ms[i].Bio,
Avatar: ms[i].Avatar,
Links: db.NotNull(ms[i].Links),
@ -75,7 +68,7 @@ func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error {
ms, err := s.DB.UserMembers(ctx, u.ID, isSelf)
if err != nil {
return errors.Wrap(err, "getting members")
return err
}
render.JSON(w, r, membersToMemberList(ms, isSelf))
@ -88,7 +81,7 @@ func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error {
ms, err := s.DB.UserMembers(ctx, claims.UserID, true)
if err != nil {
return errors.Wrap(err, "getting members")
return err
}
render.JSON(w, r, membersToMemberList(ms, true))

View file

@ -4,16 +4,14 @@ import (
"fmt"
"net/http"
"strings"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/common"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)
@ -27,7 +25,6 @@ type PatchMemberRequest struct {
Fields *[]db.Field `json:"fields"`
Avatar *string `json:"avatar"`
Unlisted *bool `json:"unlisted"`
Flags *[]xid.ID `json:"flags"`
}
func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
@ -39,16 +36,17 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
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)
if err != nil {
return errors.Wrap(err, "getting user")
}
var m db.Member
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
log.Debugf("%v/%v is xid", chi.URLParam(r, "memberRef"), id)
m, err = s.DB.Member(ctx, id)
m, err := s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
@ -56,21 +54,6 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "getting member")
}
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
return errors.Wrap(err, "getting member")
}
}
if m.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotOwnMember}
@ -91,8 +74,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
req.Fields == nil &&
req.Names == nil &&
req.Pronouns == nil &&
req.Avatar == nil &&
req.Flags == nil {
req.Avatar == nil {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Data must not be empty",
@ -127,7 +109,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
if !db.MemberNameValid(*req.Name) {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Member name cannot contain any of the following: @, \\, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, , and cannot be one or two periods.",
Details: "Member name cannot contain any of the following: @, \\, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, ,",
}
}
}
@ -171,16 +153,6 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
}
}
// validate flag length
if req.Flags != nil {
if len(*req.Flags) > db.MaxPrideFlags {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Too many flags (max %d, current %d)", len(*req.Flags), db.MaxPrideFlags),
}
}
}
if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil {
return *err
}
@ -221,13 +193,13 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
}
log.Errorf("converting member avatar: %v", err)
return errors.Wrap(err, "converting member avatar")
return err
}
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
if err != nil {
log.Errorf("uploading member avatar: %v", err)
return errors.Wrap(err, "writing member avatar")
return err
}
avatarHash = &hash
@ -245,16 +217,11 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
tx, err := s.DB.Begin(ctx)
if err != nil {
log.Errorf("creating transaction: %v", err)
return errors.Wrap(err, "creating transaction")
return err
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
defer tx.Rollback(ctx)
m, err = s.DB.UpdateMember(ctx, tx, m.ID, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash)
m, err = s.DB.UpdateMember(ctx, tx, id, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash)
if err != nil {
switch errors.Cause(err) {
case db.ErrNothingToUpdate:
@ -278,10 +245,10 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
pronouns = *req.Pronouns
}
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, names, pronouns)
err = s.DB.SetMemberNamesPronouns(ctx, tx, id, names, pronouns)
if err != nil {
log.Errorf("setting names for member %v: %v", m.ID, err)
return errors.Wrap(err, "setting names/pronouns")
log.Errorf("setting names for member %v: %v", id, err)
return err
}
m.Names = names
m.Pronouns = pronouns
@ -289,114 +256,27 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
var fields []db.Field
if req.Fields != nil {
err = s.DB.SetMemberFields(ctx, tx, m.ID, *req.Fields)
err = s.DB.SetMemberFields(ctx, tx, id, *req.Fields)
if err != nil {
log.Errorf("setting fields for member %v: %v", m.ID, err)
return errors.Wrap(err, "setting fields")
log.Errorf("setting fields for member %v: %v", id, err)
return err
}
fields = *req.Fields
} else {
fields, err = s.DB.MemberFields(ctx, m.ID)
fields, err = s.DB.MemberFields(ctx, id)
if err != nil {
log.Errorf("getting fields for member %v: %v", m.ID, err)
return errors.Wrap(err, "getting fields")
log.Errorf("getting fields for member %v: %v", id, err)
return err
}
}
// update flags
if req.Flags != nil {
err = s.DB.SetMemberFlags(ctx, tx, m.ID, *req.Flags)
if err != nil {
if err == db.ErrInvalidFlagID {
return server.APIError{Code: server.ErrBadRequest, Details: "One or more flag IDs are unknown"}
}
log.Errorf("updating flags for member %v: %v", m.ID, err)
return errors.Wrap(err, "updating flags")
}
}
// update last active time
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "updating last active time")
}
err = tx.Commit(ctx)
if err != nil {
log.Errorf("committing transaction: %v", 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)
flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return errors.Wrap(err, "getting flags")
return err
}
// echo the updated member back on success
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))
return nil
}
func (s *Server) rerollMemberSID(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
var m db.Member
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
if err != nil {
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)
if err != nil {
return errors.Wrap(err, "getting user")
}
if m.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotOwnMember}
}
if time.Now().Add(-time.Hour).Before(u.LastSIDReroll) {
return server.APIError{Code: server.ErrRerollingTooQuickly}
}
newID, err := s.DB.RerollMemberSID(ctx, u.ID, m.ID)
if err != nil {
return errors.Wrap(err, "updating member SID")
}
m.SID = newID
render.JSON(w, r, dbMemberToMember(u, m, nil, nil, true))
render.JSON(w, r, dbMemberToMember(u, m, fields, true))
return nil
}

View file

@ -3,7 +3,7 @@ package member
import (
"github.com/go-chi/chi/v5"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/server"
)
type Server struct {
@ -19,7 +19,6 @@ func Mount(srv *server.Server, r chi.Router) {
// user-scoped member lookup (including custom urls)
r.Get("/users/{userRef}/members/{memberRef}", server.WrapHandler(s.getUserMember))
r.With(server.MustAuth).Get("/users/@me/members/{memberRef}", server.WrapHandler(s.getMeMember))
r.Route("/members", func(r chi.Router) {
// any member by ID
@ -29,8 +28,5 @@ func Mount(srv *server.Server, r chi.Router) {
r.With(server.MustAuth).Post("/", server.WrapHandler(s.createMember))
r.With(server.MustAuth).Patch("/{memberRef}", server.WrapHandler(s.patchMember))
r.With(server.MustAuth).Delete("/{memberRef}", server.WrapHandler(s.deleteMember))
// reroll member SID
r.With(server.MustAuth).Get("/{memberRef}/reroll", server.WrapHandler(s.rerollMemberSID))
})
}

View file

@ -0,0 +1,52 @@
package meta
import (
"net/http"
"os"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
type Server struct {
*server.Server
}
func Mount(srv *server.Server, r chi.Router) {
s := &Server{Server: srv}
r.Get("/meta", server.WrapHandler(s.meta))
}
type MetaResponse struct {
GitRepository string `json:"git_repository"`
GitCommit string `json:"git_commit"`
Users int64 `json:"users"`
Members int64 `json:"members"`
RequireInvite bool `json:"require_invite"`
}
func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
numUsers, err := s.DB.TotalUserCount(ctx)
if err != nil {
return errors.Wrap(err, "querying user count")
}
numMembers, err := s.DB.TotalMemberCount(ctx)
if err != nil {
return errors.Wrap(err, "querying user count")
}
render.JSON(w, r, MetaResponse{
GitRepository: server.Repository,
GitCommit: server.Revision,
Users: numUsers,
Members: numMembers,
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
})
return nil
}

View file

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

View file

@ -4,8 +4,8 @@ import (
"net/http"
"strconv"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"

View file

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

View file

@ -3,7 +3,7 @@ package mod
import (
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/prometheus/client_golang/prometheus/promhttp"
@ -22,8 +22,6 @@ func Mount(srv *server.Server, r chi.Router) {
r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter))
r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport))
r.Post("/notices", server.WrapHandler(s.createNotice))
})
r.With(MustAdmin).Handle("/metrics", promhttp.Handler())

View file

@ -4,9 +4,9 @@ import (
"net/http"
"strconv"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
@ -44,7 +44,7 @@ func (s *Server) ackWarning(w http.ResponseWriter, r *http.Request) (err error)
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if claims.APIToken {
if !claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}

View file

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

View file

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

View file

@ -2,13 +2,10 @@ package user
import (
"net/http"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
@ -16,8 +13,6 @@ import (
type GetUserResponse struct {
ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
SID string `json:"sid"`
Username string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
@ -29,21 +24,14 @@ type GetUserResponse struct {
Members []PartialMember `json:"members"`
Fields []db.Field `json:"fields"`
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
Flags []db.UserFlag `json:"flags"`
Badges db.Badge `json:"badges"`
UTCOffset *int `json:"utc_offset"`
}
type GetMeResponse struct {
GetUserResponse
CreatedAt time.Time `json:"created_at"`
Timezone *string `json:"timezone"`
MaxInvites int `json:"max_invites"`
IsAdmin bool `json:"is_admin"`
ListPrivate bool `json:"list_private"`
LastSIDReroll time.Time `json:"last_sid_reroll"`
Discord *string `json:"discord"`
DiscordUsername *string `json:"discord_username"`
@ -61,8 +49,6 @@ type GetMeResponse struct {
type PartialMember struct {
ID xid.ID `json:"id"`
SnowflakeID common.MemberID `json:"id_new"`
SID string `json:"sid"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
@ -72,11 +58,9 @@ type PartialMember struct {
Pronouns []db.PronounEntry `json:"pronouns"`
}
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) GetUserResponse {
resp := GetUserResponse{
ID: u.ID,
SnowflakeID: u.SnowflakeID,
SID: u.SID,
Username: u.Username,
DisplayName: u.DisplayName,
Bio: u.Bio,
@ -87,23 +71,12 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags [
Pronouns: db.NotNull(u.Pronouns),
Fields: db.NotNull(fields),
CustomPreferences: u.CustomPreferences,
Flags: flags,
}
if u.IsAdmin {
resp.Badges |= db.BadgeAdmin
}
if offset, ok := u.UTCOffset(); ok {
resp.UTCOffset = &offset
}
resp.Members = make([]PartialMember, len(members))
for i := range members {
resp.Members[i] = PartialMember{
ID: members[i].ID,
SnowflakeID: members[i].SnowflakeID,
SID: members[i].SID,
Name: members[i].Name,
DisplayName: members[i].DisplayName,
Bio: members[i].Bio,
@ -117,38 +90,56 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags [
return resp
}
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
userRef := chi.URLParamFromCtx(ctx, "userRef")
var u db.User
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 u.DeletedAt != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
isSelf := false
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
isSelf = true
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
log.Errorf("getting user by ID: %v", err)
log.Errorf("Error getting user fields: %v", err)
return err
}
var members []db.Member
if !u.ListPrivate || isSelf {
members, err = s.DB.UserMembers(ctx, u.ID, isSelf)
if err != nil {
log.Errorf("Error getting user members: %v", err)
return err
}
}
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)
}
render.JSON(w, r, dbUserToResponse(u, fields, members))
return nil
} else if err != db.ErrUserNotFound {
log.Errorf("Error getting user by ID: %v", err)
return err
}
// otherwise, we fall back to checking usernames
}
if u.ID.IsNil() {
u, err = s.DB.Username(ctx, userRef)
u, err := s.DB.Username(ctx, userRef)
if err == db.ErrUserNotFound {
return server.APIError{
Code: server.ErrUserNotFound,
}
} else if err != nil {
log.Errorf("Error getting user by username: %v", err)
return errors.Wrap(err, "getting user")
}
return err
}
if u.DeletedAt != nil {
@ -163,13 +154,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
log.Errorf("Error getting user fields: %v", err)
return errors.Wrap(err, "getting fields")
}
flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return errors.Wrap(err, "getting flags")
return err
}
var members []db.Member
@ -177,11 +162,11 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
members, err = s.DB.UserMembers(ctx, u.ID, isSelf)
if err != nil {
log.Errorf("Error getting user members: %v", err)
return errors.Wrap(err, "getting user members")
return err
}
}
render.JSON(w, r, dbUserToResponse(u, fields, members, flags))
render.JSON(w, r, dbUserToResponse(u, fields, members))
return nil
}
@ -192,35 +177,26 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
log.Errorf("Error getting user: %v", err)
return errors.Wrap(err, "getting users")
return err
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
log.Errorf("Error getting user fields: %v", err)
return errors.Wrap(err, "getting fields")
return err
}
members, err := s.DB.UserMembers(ctx, u.ID, true)
if err != nil {
log.Errorf("Error getting user members: %v", err)
return errors.Wrap(err, "getting members")
}
flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return errors.Wrap(err, "getting flags")
return err
}
render.JSON(w, r, GetMeResponse{
GetUserResponse: dbUserToResponse(u, fields, members, flags),
CreatedAt: u.ID.Time(),
Timezone: u.Timezone,
GetUserResponse: dbUserToResponse(u, fields, members),
MaxInvites: u.MaxInvites,
IsAdmin: u.IsAdmin,
ListPrivate: u.ListPrivate,
LastSIDReroll: u.LastSIDReroll,
Discord: u.Discord,
DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr,

View file

@ -3,21 +3,18 @@ package user
import (
"fmt"
"net/http"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/common"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)
type PatchUserRequest struct {
Username *string `json:"name"`
Username *string `json:"username"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
MemberTitle *string `json:"member_title"`
@ -26,10 +23,8 @@ type PatchUserRequest struct {
Pronouns *[]db.PronounEntry `json:"pronouns"`
Fields *[]db.Field `json:"fields"`
Avatar *string `json:"avatar"`
Timezone *string `json:"timezone"`
ListPrivate *bool `json:"list_private"`
CustomPreferences *db.CustomPreferences `json:"custom_preferences"`
Flags *[]xid.ID `json:"flags"`
}
// patchUser parses a PatchUserRequest and updates the user with the given ID.
@ -65,8 +60,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
req.Names == nil &&
req.Pronouns == nil &&
req.Avatar == nil &&
req.CustomPreferences == nil &&
req.Flags == nil {
req.CustomPreferences == nil {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Data must not be empty",
@ -93,19 +87,6 @@ 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
if req.Links != nil {
if len(*req.Links) > db.MaxUserLinksLength {
@ -125,14 +106,16 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
}
}
// validate flag length
if req.Flags != nil {
if len(*req.Flags) > db.MaxPrideFlags {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Too many flags (max %d, current %d)", len(*req.Flags), db.MaxPrideFlags),
if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil {
return *err
}
if err := validateSlicePtr("pronoun", req.Pronouns, u.CustomPreferences); err != nil {
return *err
}
if err := validateSlicePtr("field", req.Fields, u.CustomPreferences); err != nil {
return *err
}
// validate custom preferences
@ -151,22 +134,6 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
}
}
}
customPreferences := u.CustomPreferences
if req.CustomPreferences != nil {
customPreferences = *req.CustomPreferences
}
if err := validateSlicePtr("name", req.Names, customPreferences); err != nil {
return *err
}
if err := validateSlicePtr("pronoun", req.Pronouns, customPreferences); err != nil {
return *err
}
if err := validateSlicePtr("field", req.Fields, customPreferences); err != nil {
return *err
}
// update avatar
var avatarHash *string = nil
@ -196,13 +163,13 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
}
log.Errorf("converting user avatar: %v", err)
return errors.Wrap(err, "converting avatar")
return err
}
hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg)
if err != nil {
log.Errorf("uploading user avatar: %v", err)
return errors.Wrap(err, "uploading avatar")
return err
}
avatarHash = &hash
@ -220,14 +187,9 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
tx, err := s.DB.Begin(ctx)
if err != nil {
log.Errorf("creating transaction: %v", err)
return errors.Wrap(err, "creating transaction")
return err
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
defer tx.Rollback(ctx)
// update username
if req.Username != nil && *req.Username != u.Username {
@ -238,18 +200,16 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrUsernameTaken}
case db.ErrInvalidUsername:
return server.APIError{Code: server.ErrInvalidUsername}
case db.ErrBannedUsername:
return server.APIError{Code: server.ErrInvalidUsername, Details: "That username cannot be used."}
default:
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.Timezone, req.CustomPreferences)
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.CustomPreferences)
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
log.Errorf("updating user: %v", err)
return errors.Wrap(err, "updating user")
return err
}
if req.Names != nil || req.Pronouns != nil {
@ -266,7 +226,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns)
if err != nil {
log.Errorf("setting names for member %v: %v", claims.UserID, err)
return errors.Wrap(err, "setting names/pronouns")
return err
}
u.Names = names
u.Pronouns = pronouns
@ -277,41 +237,21 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields)
if err != nil {
log.Errorf("setting fields for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "setting fields")
return err
}
fields = *req.Fields
} else {
fields, err = s.DB.UserFields(ctx, claims.UserID)
if err != nil {
log.Errorf("getting fields for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "getting fields")
return err
}
}
// update flags
if req.Flags != nil {
err = s.DB.SetUserFlags(ctx, tx, claims.UserID, *req.Flags)
if err != nil {
if err == db.ErrInvalidFlagID {
return server.APIError{Code: server.ErrBadRequest, Details: "One or more flag IDs are unknown"}
}
log.Errorf("updating flags for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "updating flags")
}
}
// update last active time
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "updating last active time")
}
err = tx.Commit(ctx)
if err != nil {
log.Errorf("committing transaction: %v", err)
return errors.Wrap(err, "committing transaction")
return err
}
// get fedi instance name if the user has a linked fedi account
@ -323,22 +263,12 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
}
}
// get flags to return (we need to return full flag objects, not the array of IDs in the request body)
flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return errors.Wrap(err, "getting flags")
}
// echo the updated user back on success
render.JSON(w, r, GetMeResponse{
GetUserResponse: dbUserToResponse(u, fields, nil, flags),
CreatedAt: u.ID.Time(),
Timezone: u.Timezone,
GetUserResponse: dbUserToResponse(u, fields, nil),
MaxInvites: u.MaxInvites,
IsAdmin: u.IsAdmin,
ListPrivate: u.ListPrivate,
LastSIDReroll: u.LastSIDReroll,
Discord: u.Discord,
DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr,
@ -388,31 +318,3 @@ func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPrefe
return nil
}
func (s *Server) rerollUserSID(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting existing user")
}
if time.Now().Add(-time.Hour).Before(u.LastSIDReroll) {
return server.APIError{Code: server.ErrRerollingTooQuickly}
}
newID, err := s.DB.RerollUserSID(ctx, u.ID)
if err != nil {
return errors.Wrap(err, "updating user SID")
}
u.SID = newID
render.JSON(w, r, dbUserToResponse(u, nil, nil, nil))
return nil
}

View file

@ -3,7 +3,7 @@ package user
import (
"os"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
)
@ -29,13 +29,6 @@ func Mount(srv *server.Server, r chi.Router) {
r.Get("/@me/export/start", server.WrapHandler(s.startExport))
r.Get("/@me/export", server.WrapHandler(s.getExport))
r.Get("/@me/flags", server.WrapHandler(s.getUserFlags))
r.Post("/@me/flags", server.WrapHandler(s.postUserFlag))
r.Patch("/@me/flags/{flagID}", server.WrapHandler(s.patchUserFlag))
r.Delete("/@me/flags/{flagID}", server.WrapHandler(s.deleteUserFlag))
r.Get("/@me/reroll", server.WrapHandler(s.rerollUserSID))
})
})
}

View file

@ -1,57 +0,0 @@
package auth
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
)
const hcaptchaURL = "https://hcaptcha.com/siteverify"
type hcaptchaResponse struct {
Success bool `json:"success"`
}
// verifyCaptcha verifies a captcha response.
func (s *Server) verifyCaptcha(ctx context.Context, response string) (ok bool, err error) {
vals := url.Values{
"response": []string{response},
"secret": []string{s.hcaptchaSecret},
"sitekey": []string{s.hcaptchaSitekey},
}
req, err := http.NewRequestWithContext(ctx, "POST", hcaptchaURL, strings.NewReader(vals.Encode()))
if err != nil {
return false, errors.Wrap(err, "creating request")
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false, errors.Wrap(err, "sending request")
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return false, errors.Sentinel("error status code")
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return false, errors.Wrap(err, "reading body")
}
var hr hcaptchaResponse
err = json.Unmarshal(b, &hr)
if err != nil {
return false, errors.Wrap(err, "unmarshaling json")
}
return hr.Success, nil
}

View file

@ -1,74 +0,0 @@
package member
import (
"net/http"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
)
func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "this token is read-only"}
}
var m db.Member
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
return errors.Wrap(err, "getting member")
}
} else if id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
return errors.Wrap(err, "getting member")
}
} else {
return server.APIError{Code: server.ErrMemberNotFound}
}
if m.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotOwnMember}
}
err = s.DB.DeleteMember(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "deleting member")
}
if m.Avatar != nil {
err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar)
if err != nil {
return errors.Wrap(err, "deleting member avatar")
}
}
// update last active time
err = s.DB.UpdateActiveTime(ctx, s.DB, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "updating last active time")
}
render.NoContent(w, r)
return nil
}

View file

@ -1,223 +0,0 @@
package member
import (
"context"
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
)
type GetMemberResponse struct {
ID xid.ID `json:"id"`
SnowflakeID common.MemberID `json:"id_new"`
SID string `json:"sid"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
Avatar *string `json:"avatar"`
Links []string `json:"links"`
Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"`
Fields []db.Field `json:"fields"`
Flags []db.MemberFlag `json:"flags"`
User PartialUser `json:"user"`
Unlisted *bool `json:"unlisted,omitempty"`
}
func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.MemberFlag, isOwnMember bool) GetMemberResponse {
r := GetMemberResponse{
ID: m.ID,
SnowflakeID: m.SnowflakeID,
SID: m.SID,
Name: m.Name,
DisplayName: m.DisplayName,
Bio: m.Bio,
Avatar: m.Avatar,
Links: db.NotNull(m.Links),
Names: db.NotNull(m.Names),
Pronouns: db.NotNull(m.Pronouns),
Fields: db.NotNull(fields),
Flags: flags,
User: PartialUser{
ID: u.ID,
SnowflakeID: u.SnowflakeID,
Username: u.Username,
DisplayName: u.DisplayName,
Avatar: u.Avatar,
CustomPreferences: u.CustomPreferences,
},
}
if isOwnMember {
r.Unlisted = &m.Unlisted
}
return r
}
type PartialUser struct {
ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
Username string `json:"name"`
DisplayName *string `json:"display_name"`
Avatar *string `json:"avatar"`
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
}
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
var m db.Member
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.Member(ctx, id)
if err != nil {
log.Errorf("getting member by xid: %v", err)
}
}
// xid was not valid
if !m.SnowflakeID.IsValid() {
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
}
u, err := s.DB.User(ctx, m.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
isOwnMember := false
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
isOwnMember = true
}
fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "getting member fields")
}
flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "getting member flags")
}
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
return nil
}
func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
u, err := s.parseUser(ctx, chi.URLParam(r, "userRef"))
if err != nil {
return server.APIError{
Code: server.ErrUserNotFound,
}
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
isOwnMember := false
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
isOwnMember = true
}
m, err := s.DB.UserMember(ctx, u.ID, chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "getting member fields")
}
flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "getting member flags")
}
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
return nil
}
func (s *Server) getMeMember(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting me user")
}
m, err := s.DB.UserMember(ctx, claims.UserID, chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "getting member fields")
}
flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "getting member flags")
}
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))
return nil
}
func (s *Server) parseUser(ctx context.Context, userRef string) (u db.User, err error) {
// check xid first
if id, err := xid.FromString(userRef); err == nil {
u, err := s.DB.User(ctx, id)
if err == 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)
}

View file

@ -1,76 +0,0 @@
package meta
import (
"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/go-chi/chi/v5"
"github.com/go-chi/render"
)
type Server struct {
*server.Server
}
func Mount(srv *server.Server, r chi.Router) {
s := &Server{Server: srv}
r.Get("/meta", server.WrapHandler(s.meta))
}
type MetaResponse struct {
GitRepository string `json:"git_repository"`
GitCommit string `json:"git_commit"`
Users MetaUsers `json:"users"`
Members int64 `json:"members"`
RequireInvite bool `json:"require_invite"`
Notice *MetaNotice `json:"notice"`
}
type MetaNotice struct {
ID int `json:"id"`
Notice string `json:"notice"`
}
type MetaUsers struct {
Total int64 `json:"total"`
ActiveMonth int64 `json:"active_month"`
ActiveWeek int64 `json:"active_week"`
ActiveDay int64 `json:"active_day"`
}
func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
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{
GitRepository: server.Repository,
GitCommit: server.Revision,
Users: MetaUsers{
Total: numUsers,
ActiveMonth: activeMonth,
ActiveWeek: activeWeek,
ActiveDay: activeDay,
},
Members: numMembers,
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
Notice: notice,
})
return nil
}

View file

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

View file

@ -1,251 +0,0 @@
package user
import (
"context"
"fmt"
"net/http"
"strings"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)
func (s *Server) getUserFlags(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
if err != nil {
return errors.Wrapf(err, "getting flags for account %v", claims.UserID)
}
render.JSON(w, r, flags)
return nil
}
type postUserFlagRequest struct {
Flag string `json:"flag"`
Name string `json:"name"`
Description string `json:"description"`
}
func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting current user flags")
}
if len(flags) >= db.MaxPrideFlags {
return server.APIError{
Code: server.ErrFlagLimitReached,
}
}
var req postUserFlagRequest
err = render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
// remove whitespace from all fields
req.Name = strings.TrimSpace(req.Name)
req.Description = strings.TrimSpace(req.Description)
if s := common.StringLength(&req.Name); s > db.MaxPrideFlagTitleLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("name too long, must be %v characters or less, is %v", db.MaxPrideFlagTitleLength, s),
}
}
if s := common.StringLength(&req.Description); s > db.MaxPrideFlagDescLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("description too long, must be %v characters or less, is %v", db.MaxPrideFlagDescLength, s),
}
}
tx, err := s.DB.Begin(ctx)
if err != nil {
return errors.Wrap(err, "starting transaction")
}
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)
if err != nil {
log.Errorf("creating flag: %v", err)
return errors.Wrap(err, "creating flag")
}
webp, err := s.DB.ConvertFlag(req.Flag)
if err != nil {
if err == db.ErrInvalidDataURI {
return server.APIError{Code: server.ErrBadRequest, Message: "invalid data URI"}
} else if err == db.ErrFileTooLarge {
return server.APIError{Code: server.ErrBadRequest, Message: "data URI exceeds 512 KB"}
}
return errors.Wrap(err, "converting flag")
}
hash, err := s.DB.WriteFlag(ctx, flag.ID, webp)
if err != nil {
return errors.Wrap(err, "writing flag")
}
flag, err = s.DB.EditFlag(ctx, tx, flag.ID, nil, nil, &hash)
if err != nil {
return errors.Wrap(err, "setting hash for flag")
}
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
render.JSON(w, r, flag)
return nil
}
type patchUserFlagRequest struct {
Name *string `json:"name"`
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 {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting current user flags")
}
flag, ok := s.parseFlag(ctx, flags, chi.URLParam(r, "flagID"))
if !ok {
return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"}
}
var req patchUserFlagRequest
err = render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if req.Name != nil {
*req.Name = strings.TrimSpace(*req.Name)
}
if req.Description != nil {
*req.Description = strings.TrimSpace(*req.Description)
}
if req.Name == nil && req.Description == nil {
return server.APIError{Code: server.ErrBadRequest, Details: "Request cannot be empty"}
}
if s := common.StringLength(req.Name); s > db.MaxPrideFlagTitleLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("name too long, must be %v characters or less, is %v", db.MaxPrideFlagTitleLength, s),
}
}
if s := common.StringLength(req.Description); s > db.MaxPrideFlagDescLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("description too long, must be %v characters or less, is %v", db.MaxPrideFlagDescLength, s),
}
}
tx, err := s.DB.Begin(ctx)
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
flag, err = s.DB.EditFlag(ctx, tx, flag.ID, req.Name, req.Description, nil)
if err != nil {
return errors.Wrap(err, "updating flag")
}
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
render.JSON(w, r, flag)
return nil
}
func (s *Server) deleteUserFlag(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting current user flags")
}
flag, ok := s.parseFlag(ctx, flags, chi.URLParam(r, "flagID"))
if !ok {
return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"}
}
if flag.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"}
}
err = s.DB.DeleteFlag(ctx, flag.ID, flag.Hash)
if err != nil {
return errors.Wrap(err, "deleting flag")
}
render.NoContent(w, r)
return nil
}

View file

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

View file

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

View file

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

View file

@ -5,8 +5,8 @@ import (
"net/http"
"strings"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server/auth"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server/auth"
"github.com/go-chi/render"
)

View file

@ -6,8 +6,8 @@ import (
"os"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/golang-jwt/jwt/v4"
"github.com/rs/xid"

View file

@ -1,14 +1,10 @@
package server
import (
"context"
"fmt"
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi/v5"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"github.com/go-chi/render"
)
@ -16,11 +12,6 @@ import (
// The inner HandlerFunc additionally returns an error.
func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
hub := sentry.GetHubFromContext(r.Context())
if hub == nil {
hub = sentry.CurrentHub().Clone()
}
err := hn(w, r)
if err != nil {
// if the function returned an API error, just render that verbatim
@ -33,20 +24,10 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han
return
}
rctx := chi.RouteContext(r.Context())
hub.ConfigureScope(func(scope *sentry.Scope) {
scope.SetTag("method", rctx.RouteMethod)
scope.SetTag("path", rctx.RoutePattern())
})
// otherwise, we log the error and return an internal server error message
log.Errorf("error in http handler: %v", err)
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 := APIError{Code: ErrInternalServerError}
apiErr.prepare()
render.Status(r, apiErr.Status)
@ -55,15 +36,10 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han
}
}
func isExpectedError(err error) bool {
return errors.Is(err, context.Canceled)
}
// APIError is an object returned by the API when an error occurs.
// It implements the error interface and can be returned by handlers.
type APIError struct {
Code int `json:"code"`
ID *sentry.EventID `json:"id,omitempty"`
Message string `json:"message,omitempty"`
Details string `json:"details,omitempty"`
@ -121,13 +97,10 @@ const (
ErrAlreadyLinked = 1014 // user already has linked account of the same type
ErrNotLinked = 1015 // user already doesn't have a linked account
ErrLastProvider = 1016 // unlinking provider would leave account with no authentication method
ErrInvalidCaptcha = 1017 // invalid or missing captcha response
// User-related error codes
ErrUserNotFound = 2001
ErrMemberListPrivate = 2002
ErrFlagLimitReached = 2003
ErrRerollingTooQuickly = 2004
// Member-related error codes
ErrMemberNotFound = 3001
@ -168,12 +141,9 @@ var errCodeMessages = map[int]string{
ErrAlreadyLinked: "Your account is already linked to an account of this type",
ErrNotLinked: "Your account is already not linked to an account of this type",
ErrLastProvider: "This is your account's only authentication provider",
ErrInvalidCaptcha: "Invalid or missing captcha response",
ErrUserNotFound: "User not found",
ErrMemberListPrivate: "This user's member list is private",
ErrFlagLimitReached: "Maximum number of pride flags reached",
ErrRerollingTooQuickly: "You can only reroll one short ID per hour.",
ErrMemberListPrivate: "This user's member list is private.",
ErrMemberNotFound: "Member not found",
ErrMemberLimitReached: "Member limit reached",
@ -211,12 +181,9 @@ var errCodeStatuses = map[int]int{
ErrAlreadyLinked: http.StatusBadRequest,
ErrNotLinked: http.StatusBadRequest,
ErrLastProvider: http.StatusBadRequest,
ErrInvalidCaptcha: http.StatusBadRequest,
ErrUserNotFound: http.StatusNotFound,
ErrMemberListPrivate: http.StatusForbidden,
ErrFlagLimitReached: http.StatusBadRequest,
ErrRerollingTooQuickly: http.StatusForbidden,
ErrMemberNotFound: http.StatusNotFound,
ErrMemberLimitReached: http.StatusBadRequest,

View file

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

View file

@ -6,15 +6,14 @@ import (
"strconv"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/server/auth"
"codeberg.org/pronounscc/pronouns.cc/backend/server/rate"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/server/auth"
"codeberg.org/u1f320/pronouns.cc/backend/server/rate"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"github.com/go-chi/render"
chiprometheus "github.com/toshi0607/chi-prometheus"
)
// Revision is the git commit, filled at build time
@ -24,7 +23,7 @@ var (
)
// Repository is the URL of the git repository
const Repository = "https://codeberg.org/pronounscc/pronouns.cc"
const Repository = "https://codeberg.org/u1f320/pronouns.cc"
type Server struct {
Router *chi.Mux
@ -50,25 +49,15 @@ func New() (*Server, error) {
s.Router.Use(middleware.Logger)
}
s.Router.Use(middleware.Recoverer)
// add Sentry tracing handler
s.Router.Use(s.sentry)
// add CORS
s.Router.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://*", "http://*"},
// Allow all methods normally used by the API
AllowedMethods: []string{"HEAD", "GET", "POST", "PATCH", "DELETE"},
AllowedMethods: []string{"HEAD", "GET"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: false,
MaxAge: 300,
}))
// enable request latency tracking
os.Setenv(chiprometheus.EnvChiPrometheusLatencyBuckets, "10,25,50,100,300,500,1000,5000")
prom := chiprometheus.New("pronouns.cc")
s.Router.Use(prom.Handler)
prom.MustRegisterDefault()
// enable authentication for all routes (but don't require it)
s.Router.Use(s.maybeAuth)
@ -100,23 +89,23 @@ func New() (*Server, error) {
// set scopes
// users
_ = rateLimiter.Scope("GET", "/users/*", 60)
_ = rateLimiter.Scope("PATCH", "/users/@me", 10)
rateLimiter.Scope("GET", "/users/*", 60)
rateLimiter.Scope("PATCH", "/users/@me", 10)
// 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("GET", "/members/*", 60)
_ = rateLimiter.Scope("PATCH", "/members/*", 20)
_ = rateLimiter.Scope("DELETE", "/members/*", 5)
rateLimiter.Scope("POST", "/members", 10)
rateLimiter.Scope("GET", "/members/*", 60)
rateLimiter.Scope("PATCH", "/members/*", 20)
rateLimiter.Scope("DELETE", "/members/*", 5)
// auth
_ = rateLimiter.Scope("*", "/auth/*", 20)
_ = rateLimiter.Scope("*", "/auth/tokens", 10)
_ = rateLimiter.Scope("*", "/auth/invites", 10)
_ = rateLimiter.Scope("POST", "/auth/discord/*", 10)
rateLimiter.Scope("*", "/auth/*", 20)
rateLimiter.Scope("*", "/auth/tokens", 10)
rateLimiter.Scope("*", "/auth/invites", 10)
rateLimiter.Scope("POST", "/auth/discord/*", 10)
s.Router.Use(rateLimiter.Handler())

View file

@ -1 +0,0 @@
cache/

View file

@ -1,41 +0,0 @@
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" },
],
},
],
},
});

View file

@ -1,4 +0,0 @@
:root {
--vp-font-family-base: "FiraGO", sans-serif;
--vp-font-family-mono: "Fira Mono", monospace;
}

View file

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

View file

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

View file

@ -1,125 +0,0 @@
# 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`.

View file

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

View file

@ -1,145 +0,0 @@
# 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`.

View file

@ -1,34 +0,0 @@
# 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. |

View file

@ -1,95 +0,0 @@
# 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` |

View file

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

View file

@ -1,12 +0,0 @@
http://pronouns.local {
handle /media* {
uri path_regexp ^/media /pronouns.cc
reverse_proxy localhost:9000
}
handle_path /api* {
reverse_proxy localhost:8080
}
reverse_proxy localhost:5173
}

View file

@ -1,5 +0,0 @@
# 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.*

View file

@ -1,14 +0,0 @@
{
"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
View file

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

View file

@ -6,11 +6,12 @@ You might have to change paths and ports, but they should work fine as-is.
## Building pronouns.cc
```bash
git clone https://codeberg.org/pronounscc/pronouns.cc.git pronouns
git clone https://codeberg.org/u1f320/pronouns.cc.git pronouns
cd pronouns
git checkout stable
make all
# if required fonts have not been downloaded yet
./download-fonts.sh
# if running for the first time
./pronouns database migrate
```
@ -22,7 +23,7 @@ one in the repository root (for the backend) and one in the frontend directory.
### Backend keys
- `HMAC_KEY`: the key used to sign tokens. This should be a base64 string, you can generate one with `go run -v . generate key` (or `./pronouns generate key` after building).
- `HMAC_KEY`: the key used to sign tokens. This should be a base64 string, you can generate one with `scripts/genkey`.
- `DATABASE_URL`: the URL for the PostgreSQL database.
- `REDIS`: the URL for the Redis database.
- `PORT` (int): the port the backend will listen on.
@ -44,8 +45,6 @@ one in the repository root (for the backend) and one in the frontend directory.
- `PUBLIC_BASE_URL`: the base URL for the frontend.
- `PRIVATE_SENTRY_DSN`: your Sentry DSN.
- `PUBLIC_MEDIA_URL`: the base URL for media.
If you're proxying your media through nginx as in `pronounscc.nginx`, set this to `$PUBLIC_BASE_URL/media`.
## Updating
@ -63,6 +62,9 @@ systemctl start pronouns-exporter # if the exporter was stopped
Both the backend and frontend are expected to run behind a reverse proxy such as Caddy or nginx.
This directory contains a sample configuration file for nginx.
Every path should be proxied to the frontend, except for `/api/`:
this should be proxied to the backend, with the URL being rewritten to remove `/api`
(for example, a request to `$DOMAIN/api/v1/users/@me` should be proxied to `localhost:8080/v1/users/@me`)
Every path should be proxied to the frontend, except:
- `/api/`: this should be proxied to the backend, with the URL being rewritten to remove `/api`
(for example, a request to `$DOMAIN/api/v1/users/@me` should be proxied to `localhost:8080/v1/users/@me`)
- `/media/`: this should be proxied to your object storage.
Make sure to rewrite `/media` into your storage bucket's name.

View file

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

Before

Width:  |  Height:  |  Size: 1.5 KiB

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