forked from mirrors/pronouns.cc
Compare commits
No commits in common. "main" and "reports" have entirely different histories.
288 changed files with 5262 additions and 24340 deletions
43
.air.toml
43
.air.toml
|
@ -1,43 +0,0 @@
|
||||||
root = "."
|
|
||||||
tmp_dir = "tmp"
|
|
||||||
|
|
||||||
[build]
|
|
||||||
args_bin = ["web"]
|
|
||||||
bin = "./tmp/main"
|
|
||||||
cmd = "go build -o ./tmp/main ."
|
|
||||||
delay = 1000
|
|
||||||
exclude_dir = ["docs", "frontend", "prns", "pronounslib", "tmp", "target", "node_modules"]
|
|
||||||
exclude_file = []
|
|
||||||
exclude_regex = ["_test.go"]
|
|
||||||
exclude_unchanged = false
|
|
||||||
follow_symlink = false
|
|
||||||
full_bin = ""
|
|
||||||
include_dir = []
|
|
||||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
|
||||||
include_file = []
|
|
||||||
kill_delay = "0s"
|
|
||||||
log = "build-errors.log"
|
|
||||||
poll = false
|
|
||||||
poll_interval = 0
|
|
||||||
rerun = false
|
|
||||||
rerun_delay = 500
|
|
||||||
send_interrupt = false
|
|
||||||
stop_on_error = false
|
|
||||||
|
|
||||||
[color]
|
|
||||||
app = ""
|
|
||||||
build = "yellow"
|
|
||||||
main = "magenta"
|
|
||||||
runner = "green"
|
|
||||||
watcher = "cyan"
|
|
||||||
|
|
||||||
[log]
|
|
||||||
main_only = false
|
|
||||||
time = false
|
|
||||||
|
|
||||||
[misc]
|
|
||||||
clean_on_exit = false
|
|
||||||
|
|
||||||
[screen]
|
|
||||||
clear_on_rebuild = false
|
|
||||||
keep_scroll = true
|
|
44
.env.example
44
.env.example
|
@ -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
3
.gitignore
vendored
|
@ -11,6 +11,3 @@ build
|
||||||
package
|
package
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
target
|
|
||||||
tmp
|
|
||||||
seed.yaml
|
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
when:
|
|
||||||
branch:
|
|
||||||
exclude: stable
|
|
||||||
|
|
||||||
steps:
|
|
||||||
check:
|
|
||||||
image: golang:alpine
|
|
||||||
commands:
|
|
||||||
- apk update && apk add curl vips-dev build-base
|
|
||||||
- make backend
|
|
||||||
# Install golangci-lint
|
|
||||||
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2
|
|
||||||
- golangci-lint run
|
|
|
@ -1,20 +0,0 @@
|
||||||
when:
|
|
||||||
branch:
|
|
||||||
exclude: stable
|
|
||||||
|
|
||||||
steps:
|
|
||||||
check:
|
|
||||||
image: node
|
|
||||||
directory: frontend
|
|
||||||
environment: # SvelteKit expects these in the environment during build time.
|
|
||||||
- PRIVATE_SENTRY_DSN=
|
|
||||||
- PUBLIC_BASE_URL=http://pronouns.localhost
|
|
||||||
- PUBLIC_MEDIA_URL=http://pronouns.localhost/media
|
|
||||||
- PUBLIC_SHORT_BASE=http://prns.localhost
|
|
||||||
- PUBLIC_HCAPTCHA_SITEKEY=non_existent_sitekey
|
|
||||||
commands:
|
|
||||||
- corepack enable
|
|
||||||
- pnpm install
|
|
||||||
- pnpm check
|
|
||||||
- pnpm lint
|
|
||||||
- pnpm build
|
|
2299
Cargo.lock
generated
2299
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,5 +0,0 @@
|
||||||
[workspace]
|
|
||||||
members = [
|
|
||||||
"pronounslib",
|
|
||||||
"prns",
|
|
||||||
]
|
|
2
Makefile
2
Makefile
|
@ -2,7 +2,7 @@ all: generate backend frontend
|
||||||
|
|
||||||
.PHONY: backend
|
.PHONY: backend
|
||||||
backend:
|
backend:
|
||||||
go build -v -o pronouns -ldflags="-buildid= -X codeberg.org/pronounscc/pronouns.cc/backend/server.Revision=`git rev-parse --short HEAD` -X codeberg.org/pronounscc/pronouns.cc/backend/server.Tag=`git describe --tags --long --always`" .
|
CGO_ENABLED=0 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
|
.PHONY: generate
|
||||||
generate:
|
generate:
|
||||||
|
|
22
README.md
22
README.md
|
@ -25,25 +25,17 @@ Requirements:
|
||||||
- PostgreSQL (any currently supported version should work)
|
- PostgreSQL (any currently supported version should work)
|
||||||
- Redis 6.0 or later
|
- Redis 6.0 or later
|
||||||
- Node.js (latest version)
|
- Node.js (latest version)
|
||||||
- MinIO **if using avatars, flags, or data exports** (_not_ required otherwise)
|
- MinIO **if using avatars or data exports** (_not_ required otherwise)
|
||||||
- [Air](https://github.com/cosmtrek/air) for live reloading the backend
|
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Create a PostgreSQL user and database (the user should own the database).
|
1. Create a PostgreSQL user and database (the user should own the database)
|
||||||
For example: `create user pronouns with password 'password'; create database pronouns with owner pronouns;`
|
For example: `create user pronouns with password 'password'; create database pronouns with owner pronouns;`
|
||||||
2. Copy `.env.example` in the repository root to a new file named `.env` and fill out the required options.
|
2. Create a `.env` file in the repository root containing at least `HMAC_KEY`, `DATABASE_URL`, `REDIS`, `PORT`, and `MINIO_ENDPOINT` keys.
|
||||||
3. Copy `frontend/.env.example` to `frontend/env` and fill out the required options.
|
3. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
|
||||||
4. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
|
4. Run `go run -v . web` to run the backend.
|
||||||
5. Run `pnpm dev`. Alternatively, if you don't want the backend to live reload, run `go run -v . web`,
|
5. Create `frontend/.env` with the following content: `PUBLIC_BASE_URL=http://localhost:5173`
|
||||||
then change to the `frontend/` directory and run `pnpm dev`.
|
6. cd into the `frontend` directory and run `pnpm dev` to run the frontend.
|
||||||
|
|
||||||
See [`docs/production.md`](/docs/production.md#configuration) for more information about keys in the backend and frontend `.env` files.
|
|
||||||
|
|
||||||
### Seeding
|
|
||||||
|
|
||||||
To seed the database with some data, create a `seed.yaml` file, then use `go run -v . database seed`.
|
|
||||||
For the file format, refer to the `Seed` struct in `scripts/seeddb`.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
// Package common contains functions and types common to all (or most) packages.
|
|
||||||
package common
|
|
||||||
|
|
||||||
import "unicode/utf8"
|
|
||||||
|
|
||||||
func StringLength(s *string) int {
|
|
||||||
if s == nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
return utf8.RuneCountInString(*s)
|
|
||||||
}
|
|
|
@ -1,65 +0,0 @@
|
||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math/rand"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Generator is a snowflake generator.
|
|
||||||
// For compatibility with other snowflake implementations, both worker and PID are set,
|
|
||||||
// but they are randomized for every generator.
|
|
||||||
type IDGenerator struct {
|
|
||||||
inc *uint64
|
|
||||||
worker, pid uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaultGenerator = NewIDGenerator(0, 0)
|
|
||||||
|
|
||||||
// NewIDGenerator creates a new ID generator with the given worker and pid.
|
|
||||||
// If worker or pid is empty, it will be set to a random number.
|
|
||||||
func NewIDGenerator(worker, pid uint64) *IDGenerator {
|
|
||||||
if worker == 0 {
|
|
||||||
worker = rand.Uint64()
|
|
||||||
}
|
|
||||||
if pid == 0 {
|
|
||||||
pid = rand.Uint64()
|
|
||||||
}
|
|
||||||
|
|
||||||
g := &IDGenerator{
|
|
||||||
inc: new(uint64),
|
|
||||||
worker: worker % 32,
|
|
||||||
pid: pid % 32,
|
|
||||||
}
|
|
||||||
|
|
||||||
return g
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateID generates a new snowflake with the default generator.
|
|
||||||
// If you need to customize the worker and PID, manually call (*Generator).Generate.
|
|
||||||
func GenerateID() Snowflake {
|
|
||||||
return defaultGenerator.Generate()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateID generates a new snowflake with the given time with the default generator.
|
|
||||||
// If you need to customize the worker and PID, manually call (*Generator).GenerateWithTime.
|
|
||||||
func GenerateIDWithTime(t time.Time) Snowflake {
|
|
||||||
return defaultGenerator.GenerateWithTime(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate generates a snowflake with the current time.
|
|
||||||
func (g *IDGenerator) Generate() Snowflake {
|
|
||||||
return g.GenerateWithTime(time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateWithTime generates a snowflake with the given time.
|
|
||||||
// To generate a snowflake for comparison, use the top-level New function instead.
|
|
||||||
func (g *IDGenerator) GenerateWithTime(t time.Time) Snowflake {
|
|
||||||
increment := atomic.AddUint64(g.inc, 1)
|
|
||||||
ts := uint64(t.UnixMilli() - Epoch)
|
|
||||||
|
|
||||||
worker := g.worker << 17
|
|
||||||
pid := g.pid << 12
|
|
||||||
|
|
||||||
return Snowflake(ts<<22 | worker | pid | (increment % 4096))
|
|
||||||
}
|
|
|
@ -1,83 +0,0 @@
|
||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Epoch is the pronouns.cc epoch (January 1st 2022 at 00:00:00 UTC) in milliseconds.
|
|
||||||
const Epoch = 1_640_995_200_000
|
|
||||||
const epochDuration = Epoch * time.Millisecond
|
|
||||||
|
|
||||||
const NullSnowflake = ^Snowflake(0)
|
|
||||||
|
|
||||||
// Snowflake is a 64-bit integer used as a unique ID, with an embedded timestamp.
|
|
||||||
type Snowflake uint64
|
|
||||||
|
|
||||||
// ID is an alias to Snowflake.
|
|
||||||
type ID = Snowflake
|
|
||||||
|
|
||||||
// ParseSnowflake parses a snowflake from a string.
|
|
||||||
func ParseSnowflake(sf string) (Snowflake, error) {
|
|
||||||
if sf == "null" {
|
|
||||||
return NullSnowflake, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
i, err := strconv.ParseUint(sf, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return Snowflake(i), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSnowflake creates a new snowflake from the given time.
|
|
||||||
func NewSnowflake(t time.Time) Snowflake {
|
|
||||||
ts := time.Duration(t.UnixNano()) - epochDuration
|
|
||||||
|
|
||||||
return Snowflake((ts / time.Millisecond) << 22)
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns the snowflake as a string.
|
|
||||||
func (s Snowflake) String() string { return strconv.FormatUint(uint64(s), 10) }
|
|
||||||
|
|
||||||
// Time returns the creation time of the snowflake.
|
|
||||||
func (s Snowflake) Time() time.Time {
|
|
||||||
ts := time.Duration(s>>22)*time.Millisecond + epochDuration
|
|
||||||
return time.Unix(0, int64(ts))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s Snowflake) IsValid() bool {
|
|
||||||
return s != 0 && s != NullSnowflake
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s Snowflake) MarshalJSON() ([]byte, error) {
|
|
||||||
if !s.IsValid() {
|
|
||||||
return []byte("null"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return []byte(`"` + strconv.FormatUint(uint64(s), 10) + `"`), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Snowflake) UnmarshalJSON(src []byte) error {
|
|
||||||
sf, err := ParseSnowflake(strings.Trim(string(src), `"`))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
*s = sf
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s Snowflake) Worker() uint8 {
|
|
||||||
return uint8(s & 0x3E0000 >> 17)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s Snowflake) PID() uint8 {
|
|
||||||
return uint8(s & 0x1F000 >> 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s Snowflake) Increment() uint16 {
|
|
||||||
return uint16(s & 0xFFF)
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
package common
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type UserID Snowflake
|
|
||||||
|
|
||||||
func (id UserID) String() string { return Snowflake(id).String() }
|
|
||||||
func (id UserID) Time() time.Time { return Snowflake(id).Time() }
|
|
||||||
func (id UserID) IsValid() bool { return Snowflake(id).IsValid() }
|
|
||||||
func (id UserID) Worker() uint8 { return Snowflake(id).Worker() }
|
|
||||||
func (id UserID) PID() uint8 { return Snowflake(id).PID() }
|
|
||||||
func (id UserID) Increment() uint16 { return Snowflake(id).Increment() }
|
|
||||||
|
|
||||||
func (id UserID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
|
|
||||||
func (id *UserID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }
|
|
||||||
|
|
||||||
type MemberID Snowflake
|
|
||||||
|
|
||||||
func (id MemberID) String() string { return Snowflake(id).String() }
|
|
||||||
func (id MemberID) Time() time.Time { return Snowflake(id).Time() }
|
|
||||||
func (id MemberID) IsValid() bool { return Snowflake(id).IsValid() }
|
|
||||||
func (id MemberID) Worker() uint8 { return Snowflake(id).Worker() }
|
|
||||||
func (id MemberID) PID() uint8 { return Snowflake(id).PID() }
|
|
||||||
func (id MemberID) Increment() uint16 { return Snowflake(id).Increment() }
|
|
||||||
|
|
||||||
func (id MemberID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
|
|
||||||
func (id *MemberID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }
|
|
||||||
|
|
||||||
type FlagID Snowflake
|
|
||||||
|
|
||||||
func (id FlagID) String() string { return Snowflake(id).String() }
|
|
||||||
func (id FlagID) Time() time.Time { return Snowflake(id).Time() }
|
|
||||||
func (id FlagID) IsValid() bool { return Snowflake(id).IsValid() }
|
|
||||||
func (id FlagID) Worker() uint8 { return Snowflake(id).Worker() }
|
|
||||||
func (id FlagID) PID() uint8 { return Snowflake(id).PID() }
|
|
||||||
func (id FlagID) Increment() uint16 { return Snowflake(id).Increment() }
|
|
||||||
|
|
||||||
func (id FlagID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
|
|
||||||
func (id *FlagID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }
|
|
|
@ -6,67 +6,141 @@ import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
_ "image/gif"
|
|
||||||
_ "image/png"
|
|
||||||
"io"
|
"io"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
|
||||||
"github.com/minio/minio-go/v7"
|
"github.com/minio/minio-go/v7"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
webpArgs = []string{"-thumbnail", "512x512^", "-gravity", "center", "-extent", "512x512", "-quality", "90", "webp:-"}
|
||||||
|
jpgArgs = []string{"-thumbnail", "512x512^", "-gravity", "center", "-extent", "512x512", "-quality", "80", "jpg:-"}
|
||||||
|
)
|
||||||
|
|
||||||
const ErrInvalidDataURI = errors.Sentinel("invalid data URI")
|
const ErrInvalidDataURI = errors.Sentinel("invalid data URI")
|
||||||
const ErrInvalidContentType = errors.Sentinel("invalid avatar content type")
|
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.
|
// ConvertAvatar parses an avatar from a data URI, converts it to WebP and JPEG, and returns the results.
|
||||||
func (db *DB) ConvertAvatar(data string) (
|
func (db *DB) ConvertAvatar(data string) (
|
||||||
webpOut *bytes.Buffer,
|
webp *bytes.Buffer,
|
||||||
jpgOut *bytes.Buffer,
|
jpg *bytes.Buffer,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
defer vips.ShutdownThread()
|
|
||||||
|
|
||||||
data = strings.TrimSpace(data)
|
data = strings.TrimSpace(data)
|
||||||
if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") {
|
if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") {
|
||||||
return nil, nil, ErrInvalidDataURI
|
return nil, nil, ErrInvalidDataURI
|
||||||
}
|
}
|
||||||
split := strings.Split(data, ",")
|
split := strings.Split(data, ",")
|
||||||
|
rest, b64 := split[0], split[1]
|
||||||
|
|
||||||
rawData, err := base64.StdEncoding.DecodeString(split[1])
|
rest = strings.Split(rest, ":")[1]
|
||||||
|
contentType := strings.Split(rest, ";")[0]
|
||||||
|
|
||||||
|
var contentArg []string
|
||||||
|
switch contentType {
|
||||||
|
case "image/png":
|
||||||
|
contentArg = []string{"png:-"}
|
||||||
|
case "image/jpeg":
|
||||||
|
contentArg = []string{"jpg:-"}
|
||||||
|
case "image/gif":
|
||||||
|
contentArg = []string{"gif:-"}
|
||||||
|
case "image/webp":
|
||||||
|
contentArg = []string{"webp:-"}
|
||||||
|
default:
|
||||||
|
return nil, nil, ErrInvalidContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
rawData, err := base64.StdEncoding.DecodeString(b64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, errors.Wrap(err, "invalid base64 data")
|
return nil, nil, errors.Wrap(err, "invalid base64 data")
|
||||||
}
|
}
|
||||||
|
|
||||||
image, err := vips.LoadImageFromBuffer(rawData, nil)
|
// create webp convert command and get its pipes
|
||||||
|
webpConvert := exec.Command("convert", append(contentArg, webpArgs...)...)
|
||||||
|
stdIn, err := webpConvert.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, errors.Wrap(err, "decoding image")
|
return nil, nil, errors.Wrap(err, "getting webp stdin")
|
||||||
|
}
|
||||||
|
stdOut, err := webpConvert.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "getting webp stdout")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = image.ThumbnailWithSize(512, 512, vips.InterestingCentre, vips.SizeBoth)
|
// start webp command
|
||||||
|
err = webpConvert.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, errors.Wrap(err, "resizing image")
|
return nil, nil, errors.Wrap(err, "starting webp command")
|
||||||
}
|
}
|
||||||
|
|
||||||
webpExport := vips.NewWebpExportParams()
|
// write data
|
||||||
webpExport.Quality = 90
|
_, err = stdIn.Write(rawData)
|
||||||
webpB, _, err := image.ExportWebp(webpExport)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, errors.Wrap(err, "exporting webp image")
|
return nil, nil, errors.Wrap(err, "writing webp data")
|
||||||
}
|
}
|
||||||
webpOut = bytes.NewBuffer(webpB)
|
err = stdIn.Close()
|
||||||
|
|
||||||
jpegExport := vips.NewJpegExportParams()
|
|
||||||
jpegExport.Quality = 80
|
|
||||||
jpegB, _, err := image.ExportJpeg(jpegExport)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, errors.Wrap(err, "exporting jpeg image")
|
return nil, nil, errors.Wrap(err, "closing webp stdin")
|
||||||
}
|
}
|
||||||
jpgOut = bytes.NewBuffer(jpegB)
|
|
||||||
|
|
||||||
return webpOut, jpgOut, nil
|
// read webp output
|
||||||
|
webpBuffer := new(bytes.Buffer)
|
||||||
|
_, err = io.Copy(webpBuffer, stdOut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "reading webp data")
|
||||||
|
}
|
||||||
|
webp = webpBuffer
|
||||||
|
|
||||||
|
// finish webp command
|
||||||
|
err = webpConvert.Wait()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "running webp command")
|
||||||
|
}
|
||||||
|
|
||||||
|
// create jpg convert command and get its pipes
|
||||||
|
jpgConvert := exec.Command("convert", append(contentArg, jpgArgs...)...)
|
||||||
|
stdIn, err = jpgConvert.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "getting jpg stdin")
|
||||||
|
}
|
||||||
|
stdOut, err = jpgConvert.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "getting jpg stdout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// start jpg command
|
||||||
|
err = jpgConvert.Start()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "starting jpg command")
|
||||||
|
}
|
||||||
|
|
||||||
|
// write data
|
||||||
|
_, err = stdIn.Write(rawData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "writing jpg data")
|
||||||
|
}
|
||||||
|
err = stdIn.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "closing jpg stdin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// read jpg output
|
||||||
|
jpgBuffer := new(bytes.Buffer)
|
||||||
|
_, err = io.Copy(jpgBuffer, stdOut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "reading jpg data")
|
||||||
|
}
|
||||||
|
jpg = jpgBuffer
|
||||||
|
|
||||||
|
// finish jpg command
|
||||||
|
err = jpgConvert.Wait()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "running jpg command")
|
||||||
|
}
|
||||||
|
|
||||||
|
return webp, jpg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) WriteUserAvatar(ctx context.Context,
|
func (db *DB) WriteUserAvatar(ctx context.Context,
|
||||||
|
@ -81,17 +155,15 @@ func (db *DB) WriteUserAvatar(ctx context.Context,
|
||||||
}
|
}
|
||||||
hash = hex.EncodeToString(hasher.Sum(nil))
|
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",
|
ContentType: "image/webp",
|
||||||
SendContentMd5: true,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.Wrap(err, "uploading webp avatar")
|
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",
|
ContentType: "image/jpeg",
|
||||||
SendContentMd5: true,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.Wrap(err, "uploading jpeg avatar")
|
return "", errors.Wrap(err, "uploading jpeg avatar")
|
||||||
|
@ -112,17 +184,15 @@ func (db *DB) WriteMemberAvatar(ctx context.Context,
|
||||||
}
|
}
|
||||||
hash = hex.EncodeToString(hasher.Sum(nil))
|
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",
|
ContentType: "image/webp",
|
||||||
SendContentMd5: true,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.Wrap(err, "uploading webp avatar")
|
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",
|
ContentType: "image/jpeg",
|
||||||
SendContentMd5: true,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.Wrap(err, "uploading jpeg avatar")
|
return "", errors.Wrap(err, "uploading jpeg avatar")
|
||||||
|
@ -132,12 +202,12 @@ func (db *DB) WriteMemberAvatar(ctx context.Context,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) DeleteUserAvatar(ctx context.Context, userID xid.ID, hash string) error {
|
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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "deleting webp avatar")
|
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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "deleting jpeg avatar")
|
return errors.Wrap(err, "deleting jpeg avatar")
|
||||||
}
|
}
|
||||||
|
@ -146,12 +216,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 {
|
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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "deleting webp avatar")
|
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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "deleting jpeg avatar")
|
return errors.Wrap(err, "deleting jpeg avatar")
|
||||||
}
|
}
|
||||||
|
@ -160,7 +230,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) {
|
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 {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "getting object")
|
return nil, errors.Wrap(err, "getting object")
|
||||||
}
|
}
|
||||||
|
@ -168,7 +238,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) {
|
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 {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "getting object")
|
return nil, errors.Wrap(err, "getting object")
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,28 +6,20 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v4/pgxpool"
|
||||||
"github.com/mediocregopher/radix/v4"
|
"github.com/mediocregopher/radix/v4"
|
||||||
"github.com/minio/minio-go/v7"
|
"github.com/minio/minio-go/v7"
|
||||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
|
var sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
|
||||||
|
|
||||||
const ErrNothingToUpdate = errors.Sentinel("nothing to update")
|
const ErrNothingToUpdate = errors.Sentinel("nothing to update")
|
||||||
|
|
||||||
const (
|
|
||||||
uniqueViolation = "23505"
|
|
||||||
foreignKeyViolation = "23503"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Execer interface {
|
type Execer interface {
|
||||||
Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error)
|
Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error)
|
||||||
}
|
}
|
||||||
|
@ -40,28 +32,19 @@ type DB struct {
|
||||||
minio *minio.Client
|
minio *minio.Client
|
||||||
minioBucket string
|
minioBucket string
|
||||||
baseURL *url.URL
|
baseURL *url.URL
|
||||||
|
|
||||||
TotalRequests prometheus.Counter
|
|
||||||
|
|
||||||
activeUsersDay, activeUsersWeek, activeUsersMonth int64
|
|
||||||
usersTotal, membersTotal int64
|
|
||||||
countMu sync.RWMutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() (*DB, error) {
|
func New() (*DB, error) {
|
||||||
log.Debug("creating postgres client")
|
pool, err := pgxpool.Connect(context.Background(), os.Getenv("DATABASE_URL"))
|
||||||
pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "creating postgres client")
|
return nil, errors.Wrap(err, "creating postgres client")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug("creating redis client")
|
|
||||||
redis, err := (&radix.PoolConfig{}).New(context.Background(), "tcp", os.Getenv("REDIS"))
|
redis, err := (&radix.PoolConfig{}).New(context.Background(), "tcp", os.Getenv("REDIS"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "creating redis client")
|
return nil, errors.Wrap(err, "creating redis client")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug("creating minio client")
|
|
||||||
minioClient, err := minio.New(os.Getenv("MINIO_ENDPOINT"), &minio.Options{
|
minioClient, err := minio.New(os.Getenv("MINIO_ENDPOINT"), &minio.Options{
|
||||||
Creds: credentials.NewStaticV4(os.Getenv("MINIO_ACCESS_KEY_ID"), os.Getenv("MINIO_ACCESS_KEY_SECRET"), ""),
|
Creds: credentials.NewStaticV4(os.Getenv("MINIO_ACCESS_KEY_ID"), os.Getenv("MINIO_ACCESS_KEY_SECRET"), ""),
|
||||||
Secure: os.Getenv("MINIO_SSL") == "true",
|
Secure: os.Getenv("MINIO_SSL") == "true",
|
||||||
|
@ -84,12 +67,6 @@ func New() (*DB, error) {
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug("initializing metrics")
|
|
||||||
err = db.initMetrics()
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "initializing metrics")
|
|
||||||
}
|
|
||||||
|
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,6 +124,30 @@ func (db *DB) GetJSON(ctx context.Context, key string, v any) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDelJSON gets the given key as a JSON object and deletes it.
|
||||||
|
func (db *DB) GetDelJSON(ctx context.Context, key string, v any) error {
|
||||||
|
var b []byte
|
||||||
|
|
||||||
|
err := db.Redis.Do(ctx, radix.Cmd(&b, "GETDEL", key))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "reading from Redis")
|
||||||
|
}
|
||||||
|
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if v == nil {
|
||||||
|
return fmt.Errorf("nil pointer passed into GetDelJSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(b, v)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "unmarshaling json")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// NotNull is a little helper that returns an *empty slice* when the slice's length is 0.
|
// NotNull is a little helper that returns an *empty slice* when the slice's length is 0.
|
||||||
// This is to prevent nil slices from being marshaled as JSON null
|
// This is to prevent nil slices from being marshaled as JSON null
|
||||||
func NotNull[T any](slice []T) []T {
|
func NotNull[T any](slice []T) []T {
|
||||||
|
|
|
@ -5,51 +5,24 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WordStatus string
|
type WordStatus int
|
||||||
|
|
||||||
func (w *WordStatus) UnmarshalJSON(src []byte) error {
|
const (
|
||||||
if string(src) == "null" {
|
StatusUnknown WordStatus = 0
|
||||||
return nil
|
StatusFavourite WordStatus = 1
|
||||||
}
|
StatusOkay WordStatus = 2
|
||||||
|
StatusJokingly WordStatus = 3
|
||||||
s := strings.Trim(string(src), `"`)
|
StatusFriendsOnly WordStatus = 4
|
||||||
switch s {
|
StatusAvoid WordStatus = 5
|
||||||
case "1":
|
wordStatusMax WordStatus = 6
|
||||||
*w = "favourite"
|
)
|
||||||
case "2":
|
|
||||||
*w = "okay"
|
|
||||||
case "3":
|
|
||||||
*w = "jokingly"
|
|
||||||
case "4":
|
|
||||||
*w = "friends_only"
|
|
||||||
case "5":
|
|
||||||
*w = "avoid"
|
|
||||||
default:
|
|
||||||
*w = WordStatus(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w WordStatus) Valid(extra CustomPreferences) bool {
|
|
||||||
if w == "favourite" || w == "okay" || w == "jokingly" || w == "friends_only" || w == "avoid" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
for k := range extra {
|
|
||||||
if string(w) == k {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type FieldEntry struct {
|
type FieldEntry struct {
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
Status WordStatus `json:"status"`
|
Status WordStatus `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fe FieldEntry) Validate(custom CustomPreferences) string {
|
func (fe FieldEntry) Validate() string {
|
||||||
if fe.Value == "" {
|
if fe.Value == "" {
|
||||||
return "value cannot be empty"
|
return "value cannot be empty"
|
||||||
}
|
}
|
||||||
|
@ -58,8 +31,8 @@ func (fe FieldEntry) Validate(custom CustomPreferences) string {
|
||||||
return fmt.Sprintf("name must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(fe.Value)))
|
return fmt.Sprintf("name must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(fe.Value)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !fe.Status.Valid(custom) {
|
if fe.Status == StatusUnknown || fe.Status >= wordStatusMax {
|
||||||
return "status is invalid"
|
return fmt.Sprintf("status is invalid, must be between 1 and %d, is %d", wordStatusMax-1, fe.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
@ -71,7 +44,7 @@ type PronounEntry struct {
|
||||||
Status WordStatus `json:"status"`
|
Status WordStatus `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p PronounEntry) Validate(custom CustomPreferences) string {
|
func (p PronounEntry) Validate() string {
|
||||||
if p.Pronouns == "" {
|
if p.Pronouns == "" {
|
||||||
return "pronouns cannot be empty"
|
return "pronouns cannot be empty"
|
||||||
}
|
}
|
||||||
|
@ -86,8 +59,8 @@ func (p PronounEntry) Validate(custom CustomPreferences) string {
|
||||||
return fmt.Sprintf("pronouns must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(p.Pronouns)))
|
return fmt.Sprintf("pronouns must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(p.Pronouns)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !p.Status.Valid(custom) {
|
if p.Status == StatusUnknown || p.Status >= wordStatusMax {
|
||||||
return "status is invalid"
|
return fmt.Sprintf("status is invalid, must be between 1 and %d, is %d", wordStatusMax-1, p.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/georgysavva/scany/v2/pgxscan"
|
"github.com/georgysavva/scany/pgxscan"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v4"
|
||||||
"github.com/minio/minio-go/v7"
|
"github.com/minio/minio-go/v7"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
@ -20,7 +20,7 @@ type DataExport struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (de DataExport) Path() string {
|
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")
|
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{
|
_, err = db.minio.PutObject(ctx, db.minioBucket, de.Path(), file, int64(file.Len()), minio.PutObjectOptions{
|
||||||
ContentType: "application/zip",
|
ContentType: "application/zip",
|
||||||
SendContentMd5: true,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return de, errors.Wrap(err, "writing export file")
|
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")
|
return de, errors.Wrap(err, "building query")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pgxscan.Get(ctx, db, &de, sql, args...)
|
pgxscan.Get(ctx, db, &de, sql, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return de, errors.Wrap(err, "executing sql")
|
return de, errors.Wrap(err, "executing sql")
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,8 +5,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/georgysavva/scany/v2/pgxscan"
|
"github.com/georgysavva/scany/pgxscan"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v4"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ type FediverseApp struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f FediverseApp) ClientConfig() *oauth2.Config {
|
func (f FediverseApp) ClientConfig() *oauth2.Config {
|
||||||
if f.MastodonCompatible() {
|
// if f.MastodonCompatible() {
|
||||||
return &oauth2.Config{
|
return &oauth2.Config{
|
||||||
ClientID: f.ClientID,
|
ClientID: f.ClientID,
|
||||||
ClientSecret: f.ClientSecret,
|
ClientSecret: f.ClientSecret,
|
||||||
|
@ -32,27 +32,13 @@ func (f FediverseApp) ClientConfig() *oauth2.Config {
|
||||||
Scopes: []string{"read:accounts"},
|
Scopes: []string{"read:accounts"},
|
||||||
RedirectURL: os.Getenv("BASE_URL") + "/auth/login/mastodon/" + f.Instance,
|
RedirectURL: os.Getenv("BASE_URL") + "/auth/login/mastodon/" + f.Instance,
|
||||||
}
|
}
|
||||||
}
|
// }
|
||||||
|
|
||||||
return &oauth2.Config{
|
// TODO: misskey, assuming i can even find english API documentation, that is
|
||||||
ClientID: f.ClientID,
|
|
||||||
ClientSecret: f.ClientSecret,
|
|
||||||
Endpoint: oauth2.Endpoint{
|
|
||||||
AuthURL: "https://" + f.Instance + "/auth",
|
|
||||||
TokenURL: "https://" + f.Instance + "/api/auth/session/oauth",
|
|
||||||
AuthStyle: oauth2.AuthStyleInHeader,
|
|
||||||
},
|
|
||||||
Scopes: []string{"read:account"},
|
|
||||||
RedirectURL: os.Getenv("BASE_URL") + "/auth/login/misskey/" + f.Instance,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f FediverseApp) MastodonCompatible() bool {
|
func (f FediverseApp) MastodonCompatible() bool {
|
||||||
return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "incestoma" || f.InstanceType == "pixelfed" || f.InstanceType == "gotosocial"
|
return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "pixelfed"
|
||||||
}
|
|
||||||
|
|
||||||
func (f FediverseApp) Misskey() bool {
|
|
||||||
return f.InstanceType == "misskey" || f.InstanceType == "foundkey" || f.InstanceType == "calckey" || f.InstanceType == "firefish" || f.InstanceType == "sharkey"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ErrNoInstanceApp = errors.Sentinel("instance doesn't have an app")
|
const ErrNoInstanceApp = errors.Sentinel("instance doesn't have an app")
|
||||||
|
|
|
@ -5,8 +5,8 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/georgysavva/scany/v2/pgxscan"
|
"github.com/georgysavva/scany/pgxscan"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ const (
|
||||||
MaxFields = 25
|
MaxFields = 25
|
||||||
FieldNameMaxLength = 100
|
FieldNameMaxLength = 100
|
||||||
FieldEntriesLimit = 100
|
FieldEntriesLimit = 100
|
||||||
FieldEntryMaxLength = 100
|
FieldEntryMaxLength = 50
|
||||||
)
|
)
|
||||||
|
|
||||||
type Field struct {
|
type Field struct {
|
||||||
|
@ -24,7 +24,7 @@ type Field struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate validates this field. If it is invalid, a non-empty string is returned as error message.
|
// Validate validates this field. If it is invalid, a non-empty string is returned as error message.
|
||||||
func (f Field) Validate(custom CustomPreferences) string {
|
func (f Field) Validate() string {
|
||||||
if f.Name == "" {
|
if f.Name == "" {
|
||||||
return "name cannot be empty"
|
return "name cannot be empty"
|
||||||
}
|
}
|
||||||
|
@ -42,11 +42,8 @@ func (f Field) Validate(custom CustomPreferences) string {
|
||||||
return fmt.Sprintf("entries.%d: max length is %d characters, length is %d", i, FieldEntryMaxLength, length)
|
return fmt.Sprintf("entries.%d: max length is %d characters, length is %d", i, FieldEntryMaxLength, length)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !entry.Status.Valid(custom) {
|
if entry.Status == StatusUnknown || entry.Status >= wordStatusMax {
|
||||||
if entry.Status == "missing" {
|
return fmt.Sprintf("entries.%d: status is invalid, must be between 1 and %d, is %d", i, wordStatusMax-1, entry.Status)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -6,10 +6,9 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/georgysavva/scany/v2/pgxscan"
|
"github.com/georgysavva/scany/pgxscan"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -44,12 +43,7 @@ func (db *DB) CreateInvite(ctx context.Context, userID xid.ID) (i Invite, err er
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return i, errors.Wrap(err, "beginning transaction")
|
return i, errors.Wrap(err, "beginning transaction")
|
||||||
}
|
}
|
||||||
defer func() {
|
defer tx.Rollback(ctx)
|
||||||
err := tx.Rollback(ctx)
|
|
||||||
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
||||||
log.Error("rolling back transaction:", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var maxInvites, inviteCount int
|
var maxInvites, inviteCount int
|
||||||
err = tx.QueryRow(ctx, "SELECT max_invites FROM users WHERE id = $1", userID).Scan(&maxInvites)
|
err = tx.QueryRow(ctx, "SELECT max_invites FROM users WHERE id = $1", userID).Scan(&maxInvites)
|
||||||
|
|
|
@ -3,16 +3,11 @@ package db
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/georgysavva/scany/pgxscan"
|
||||||
"github.com/georgysavva/scany/v2/pgxscan"
|
"github.com/jackc/pgconn"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v4"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -24,8 +19,6 @@ const (
|
||||||
type Member struct {
|
type Member struct {
|
||||||
ID xid.ID
|
ID xid.ID
|
||||||
UserID xid.ID
|
UserID xid.ID
|
||||||
SnowflakeID common.MemberID
|
|
||||||
SID string `db:"sid"`
|
|
||||||
Name string
|
Name string
|
||||||
DisplayName *string
|
DisplayName *string
|
||||||
Bio *string
|
Bio *string
|
||||||
|
@ -33,7 +26,6 @@ type Member struct {
|
||||||
Links []string
|
Links []string
|
||||||
Names []FieldEntry
|
Names []FieldEntry
|
||||||
Pronouns []PronounEntry
|
Pronouns []PronounEntry
|
||||||
Unlisted bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -42,24 +34,9 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
// member names must match this regex
|
// member names must match this regex
|
||||||
var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,\\*]{1,100}$")
|
var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"'$%&()+<=>^|~`,]{1,100}$")
|
||||||
|
|
||||||
// List of member names that cannot be used because they would break routing or be inaccessible due to page conflicts.
|
|
||||||
var invalidMemberNames = []string{
|
|
||||||
// these break routing outright
|
|
||||||
".",
|
|
||||||
"..",
|
|
||||||
// the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible
|
|
||||||
"edit",
|
|
||||||
}
|
|
||||||
|
|
||||||
func MemberNameValid(name string) bool {
|
func MemberNameValid(name string) bool {
|
||||||
for i := range invalidMemberNames {
|
|
||||||
if strings.EqualFold(name, invalidMemberNames[i]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return memberNameRegex.MatchString(name)
|
return memberNameRegex.MatchString(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,23 +53,9 @@ func (db *DB) Member(ctx context.Context, id xid.ID) (m Member, err error) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) MemberBySnowflake(ctx context.Context, id common.MemberID) (m Member, err error) {
|
|
||||||
sql, args, err := sq.Select("*").From("members").Where("snowflake_id = ?", id).ToSql()
|
|
||||||
if err != nil {
|
|
||||||
return m, errors.Wrap(err, "building sql")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = pgxscan.Get(ctx, db, &m, sql, args...)
|
|
||||||
if err != nil {
|
|
||||||
return m, errors.Wrap(err, "executing query")
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserMember returns a member scoped by user.
|
// UserMember returns a member scoped by user.
|
||||||
func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (m Member, err error) {
|
func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (m Member, err error) {
|
||||||
sf, _ := common.ParseSnowflake(memberRef) // error can be ignored as the zero value will never be used as an ID
|
sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).Where("(id = ? or name = ?)", memberRef, memberRef).ToSql()
|
||||||
sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).Where("(id = ? or snowflake_id = ? or name = ?)", memberRef, sf, memberRef).ToSql()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return m, errors.Wrap(err, "building sql")
|
return m, errors.Wrap(err, "building sql")
|
||||||
}
|
}
|
||||||
|
@ -104,35 +67,11 @@ func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (
|
||||||
return m, nil
|
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.
|
// 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) {
|
func (db *DB) UserMembers(ctx context.Context, userID xid.ID) (ms []Member, err error) {
|
||||||
builder := sq.Select("*").
|
sql, args, err := sq.Select("*").
|
||||||
From("members").Where("user_id = ?", userID).
|
From("members").Where("user_id = ?", userID).
|
||||||
OrderBy("name", "id")
|
OrderBy("name", "id").ToSql()
|
||||||
if !showHidden {
|
|
||||||
builder = builder.Where("unlisted = ?", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
sql, args, err := builder.ToSql()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "building sql")
|
return nil, errors.Wrap(err, "building sql")
|
||||||
}
|
}
|
||||||
|
@ -154,8 +93,8 @@ func (db *DB) CreateMember(
|
||||||
name string, displayName *string, bio string, links []string,
|
name string, displayName *string, bio string, links []string,
|
||||||
) (m Member, err error) {
|
) (m Member, err error) {
|
||||||
sql, args, err := sq.Insert("members").
|
sql, args, err := sq.Insert("members").
|
||||||
Columns("user_id", "snowflake_id", "id", "sid", "name", "display_name", "bio", "links").
|
Columns("user_id", "id", "name", "display_name", "bio", "links").
|
||||||
Values(userID, common.GenerateID(), xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links).
|
Values(userID, xid.New(), name, displayName, bio, links).
|
||||||
Suffix("RETURNING *").ToSql()
|
Suffix("RETURNING *").ToSql()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return m, errors.Wrap(err, "building sql")
|
return m, errors.Wrap(err, "building sql")
|
||||||
|
@ -166,7 +105,7 @@ func (db *DB) CreateMember(
|
||||||
pge := &pgconn.PgError{}
|
pge := &pgconn.PgError{}
|
||||||
if errors.As(err, &pge) {
|
if errors.As(err, &pge) {
|
||||||
// unique constraint violation
|
// unique constraint violation
|
||||||
if pge.Code == uniqueViolation {
|
if pge.Code == "23505" {
|
||||||
return m, ErrMemberNameInUse
|
return m, ErrMemberNameInUse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -209,7 +148,6 @@ func (db *DB) UpdateMember(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx pgx.Tx, id xid.ID,
|
tx pgx.Tx, id xid.ID,
|
||||||
name, displayName, bio *string,
|
name, displayName, bio *string,
|
||||||
unlisted *bool,
|
|
||||||
links *[]string,
|
links *[]string,
|
||||||
avatar *string,
|
avatar *string,
|
||||||
) (m Member, err error) {
|
) (m Member, err error) {
|
||||||
|
@ -252,9 +190,6 @@ func (db *DB) UpdateMember(
|
||||||
if links != nil {
|
if links != nil {
|
||||||
builder = builder.Set("links", *links)
|
builder = builder.Set("links", *links)
|
||||||
}
|
}
|
||||||
if unlisted != nil {
|
|
||||||
builder = builder.Set("unlisted", *unlisted)
|
|
||||||
}
|
|
||||||
|
|
||||||
if avatar != nil {
|
if avatar != nil {
|
||||||
if *avatar == "" {
|
if *avatar == "" {
|
||||||
|
@ -273,7 +208,7 @@ func (db *DB) UpdateMember(
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pge := &pgconn.PgError{}
|
pge := &pgconn.PgError{}
|
||||||
if errors.As(err, &pge) {
|
if errors.As(err, &pge) {
|
||||||
if pge.Code == uniqueViolation {
|
if pge.Code == "23505" {
|
||||||
return m, ErrMemberNameInUse
|
return m, ErrMemberNameInUse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -282,48 +217,3 @@ func (db *DB) UpdateMember(
|
||||||
}
|
}
|
||||||
return m, nil
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,198 +0,0 @@
|
||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"codeberg.org/pronounscc/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) {
|
|
||||||
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
|
||||||
Name: "pronouns_users_total",
|
|
||||||
Help: "The total number of registered users",
|
|
||||||
}, func() float64 {
|
|
||||||
count, err := db.TotalUserCount(context.Background())
|
|
||||||
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 {
|
|
||||||
return errors.Wrap(err, "registering user count gauge")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
|
||||||
Name: "pronouns_members_total",
|
|
||||||
Help: "The total number of registered members",
|
|
||||||
}, func() float64 {
|
|
||||||
count, err := db.TotalMemberCount(context.Background())
|
|
||||||
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",
|
|
||||||
})
|
|
||||||
|
|
||||||
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 {
|
|
||||||
return 0, errors.Wrap(err, "querying user count")
|
|
||||||
}
|
|
||||||
return numUsers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) TotalMemberCount(ctx context.Context) (numMembers int64, err error) {
|
|
||||||
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM members WHERE unlisted = false AND user_id = ANY(SELECT id FROM users WHERE deleted_at IS NULL)").Scan(&numMembers)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "querying member count")
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -5,8 +5,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/georgysavva/scany/v2/pgxscan"
|
"github.com/georgysavva/scany/pgxscan"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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) {
|
func (db *DB) ReportsByUser(ctx context.Context, userID xid.ID, before int) (rs []Report, err error) {
|
||||||
builder := sq.Select("*",
|
builder := sq.Select("*").From("reports").Where("user_id = ?", userID).Limit(ReportPageSize).OrderBy("id DESC")
|
||||||
"(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")
|
|
||||||
if before != 0 {
|
if before != 0 {
|
||||||
builder = builder.Where("id < ?", before)
|
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) {
|
func (db *DB) ReportsByReporter(ctx context.Context, reporterID xid.ID, before int) (rs []Report, err error) {
|
||||||
builder := sq.Select("*",
|
builder := sq.Select("*").From("reports").Where("reporter_id = ?", reporterID).Limit(ReportPageSize).OrderBy("id DESC")
|
||||||
"(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")
|
|
||||||
if before != 0 {
|
if before != 0 {
|
||||||
builder = builder.Where("id < ?", before)
|
builder = builder.Where("id < ?", before)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,8 +5,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/georgysavva/scany/v2/pgxscan"
|
"github.com/georgysavva/scany/pgxscan"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -14,8 +14,6 @@ type Token struct {
|
||||||
UserID xid.ID
|
UserID xid.ID
|
||||||
TokenID xid.ID
|
TokenID xid.ID
|
||||||
Invalidated bool
|
Invalidated bool
|
||||||
APIOnly bool `db:"api_only"`
|
|
||||||
ReadOnly bool
|
|
||||||
Created time.Time
|
Created time.Time
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
}
|
}
|
||||||
|
@ -61,18 +59,13 @@ func (db *DB) Tokens(ctx context.Context, userID xid.ID) (ts []Token, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3 months, might be customizable later
|
// 3 months, might be customizable later
|
||||||
const TokenExpiryTime = 3 * 30 * 24 * time.Hour
|
const ExpiryTime = 3 * 30 * 24 * time.Hour
|
||||||
|
|
||||||
// SaveToken saves a token to the database.
|
// SaveToken saves a token to the database.
|
||||||
func (db *DB) SaveToken(ctx context.Context, userID xid.ID, tokenID xid.ID, apiOnly, readOnly bool) (t Token, err error) {
|
func (db *DB) SaveToken(ctx context.Context, userID xid.ID, tokenID xid.ID) (t Token, err error) {
|
||||||
sql, args, err := sq.Insert("tokens").
|
sql, args, err := sq.Insert("tokens").
|
||||||
SetMap(map[string]any{
|
Columns("user_id", "token_id", "expires").
|
||||||
"user_id": userID,
|
Values(userID, tokenID, time.Now().Add(ExpiryTime)).
|
||||||
"token_id": tokenID,
|
|
||||||
"expires": time.Now().Add(TokenExpiryTime),
|
|
||||||
"api_only": apiOnly,
|
|
||||||
"read_only": readOnly,
|
|
||||||
}).
|
|
||||||
Suffix("RETURNING *").
|
Suffix("RETURNING *").
|
||||||
ToSql()
|
ToSql()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -4,31 +4,22 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/icons"
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/Masterminds/squirrel"
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"github.com/georgysavva/scany/v2/pgxscan"
|
"github.com/georgysavva/scany/pgxscan"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID xid.ID
|
ID xid.ID
|
||||||
SnowflakeID common.UserID
|
|
||||||
SID string `db:"sid"`
|
|
||||||
Username string
|
Username string
|
||||||
DisplayName *string
|
DisplayName *string
|
||||||
Bio *string
|
Bio *string
|
||||||
MemberTitle *string
|
|
||||||
LastActive time.Time
|
|
||||||
|
|
||||||
Avatar *string
|
Avatar *string
|
||||||
Links []string
|
Links []string
|
||||||
|
@ -44,63 +35,14 @@ type User struct {
|
||||||
FediverseAppID *int64
|
FediverseAppID *int64
|
||||||
FediverseInstance *string
|
FediverseInstance *string
|
||||||
|
|
||||||
Tumblr *string
|
|
||||||
TumblrUsername *string
|
|
||||||
|
|
||||||
Google *string
|
|
||||||
GoogleUsername *string
|
|
||||||
|
|
||||||
MaxInvites int
|
MaxInvites int
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
ListPrivate bool
|
|
||||||
LastSIDReroll time.Time `db:"last_sid_reroll"`
|
|
||||||
Timezone *string
|
|
||||||
Settings UserSettings
|
|
||||||
|
|
||||||
DeletedAt *time.Time
|
DeletedAt *time.Time
|
||||||
SelfDelete *bool
|
SelfDelete *bool
|
||||||
DeleteReason *string
|
DeleteReason *string
|
||||||
|
|
||||||
CustomPreferences CustomPreferences
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomPreferences = map[string]CustomPreference
|
|
||||||
|
|
||||||
type CustomPreference struct {
|
|
||||||
Icon string `json:"icon"`
|
|
||||||
Tooltip string `json:"tooltip"`
|
|
||||||
Size PreferenceSize `json:"size"`
|
|
||||||
Muted bool `json:"muted"`
|
|
||||||
Favourite bool `json:"favourite"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c CustomPreference) Validate() string {
|
|
||||||
if !icons.IsValid(c.Icon) {
|
|
||||||
return fmt.Sprintf("custom preference icon %q is invalid", c.Icon)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Tooltip == "" {
|
|
||||||
return "custom preference tooltip is empty"
|
|
||||||
}
|
|
||||||
if common.StringLength(&c.Tooltip) > FieldEntryMaxLength {
|
|
||||||
return fmt.Sprintf("custom preference tooltip is too long, max %d characters, is %d characters", FieldEntryMaxLength, common.StringLength(&c.Tooltip))
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Size != PreferenceSizeLarge && c.Size != PreferenceSizeNormal && c.Size != PreferenceSizeSmall {
|
|
||||||
return fmt.Sprintf("custom preference size %q is invalid", string(c.Size))
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type PreferenceSize string
|
|
||||||
|
|
||||||
const (
|
|
||||||
PreferenceSizeLarge PreferenceSize = "large"
|
|
||||||
PreferenceSizeNormal PreferenceSize = "normal"
|
|
||||||
PreferenceSizeSmall PreferenceSize = "small"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u User) NumProviders() (numProviders int) {
|
func (u User) NumProviders() (numProviders int) {
|
||||||
if u.Discord != nil {
|
if u.Discord != nil {
|
||||||
numProviders++
|
numProviders++
|
||||||
|
@ -108,74 +50,12 @@ func (u User) NumProviders() (numProviders int) {
|
||||||
if u.Fediverse != nil {
|
if u.Fediverse != nil {
|
||||||
numProviders++
|
numProviders++
|
||||||
}
|
}
|
||||||
if u.Tumblr != nil {
|
|
||||||
numProviders++
|
|
||||||
}
|
|
||||||
if u.Google != nil {
|
|
||||||
numProviders++
|
|
||||||
}
|
|
||||||
return numProviders
|
return numProviders
|
||||||
}
|
}
|
||||||
|
|
||||||
// UTCOffset returns the user's UTC offset in seconds. If the user does not have a timezone set, `ok` is false.
|
|
||||||
func (u User) UTCOffset() (offset int, ok bool) {
|
|
||||||
if u.Timezone == nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
loc, err := time.LoadLocation(*u.Timezone)
|
|
||||||
if err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
_, offset = time.Now().In(loc).Zone()
|
|
||||||
return offset, true
|
|
||||||
}
|
|
||||||
|
|
||||||
type Badge int32
|
|
||||||
|
|
||||||
const (
|
|
||||||
BadgeAdmin Badge = 1 << 0
|
|
||||||
)
|
|
||||||
|
|
||||||
// usernames must match this regex
|
// usernames must match this regex
|
||||||
var usernameRegex = regexp.MustCompile(`^[\w-.]{2,40}$`)
|
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 (
|
const (
|
||||||
ErrUserNotFound = errors.Sentinel("user not found")
|
ErrUserNotFound = errors.Sentinel("user not found")
|
||||||
|
|
||||||
|
@ -183,7 +63,6 @@ const (
|
||||||
ErrInvalidUsername = errors.Sentinel("username contains invalid characters")
|
ErrInvalidUsername = errors.Sentinel("username contains invalid characters")
|
||||||
ErrUsernameTooShort = errors.Sentinel("username is too short")
|
ErrUsernameTooShort = errors.Sentinel("username is too short")
|
||||||
ErrUsernameTooLong = errors.Sentinel("username is too long")
|
ErrUsernameTooLong = errors.Sentinel("username is too long")
|
||||||
ErrBannedUsername = errors.Sentinel("username is banned")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -203,11 +82,17 @@ const (
|
||||||
func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u User, err error) {
|
func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u User, err error) {
|
||||||
// check if the username is valid
|
// check if the username is valid
|
||||||
// if not, return an error depending on what failed
|
// if not, return an error depending on what failed
|
||||||
if err := UsernameValid(username); err != nil {
|
if !usernameRegex.MatchString(username) {
|
||||||
return u, err
|
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 {
|
if err != nil {
|
||||||
return u, errors.Wrap(err, "building sql")
|
return u, errors.Wrap(err, "building sql")
|
||||||
}
|
}
|
||||||
|
@ -217,7 +102,7 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use
|
||||||
pge := &pgconn.PgError{}
|
pge := &pgconn.PgError{}
|
||||||
if errors.As(err, &pge) {
|
if errors.As(err, &pge) {
|
||||||
// unique constraint violation
|
// unique constraint violation
|
||||||
if pge.Code == uniqueViolation {
|
if pge.Code == "23505" {
|
||||||
return u, ErrUsernameTaken
|
return u, ErrUsernameTaken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -353,128 +238,6 @@ func (u *User) UnlinkDiscord(ctx context.Context, ex Execer) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TumblrUser fetches a user by Tumblr user ID.
|
|
||||||
func (db *DB) TumblrUser(ctx context.Context, tumblrID string) (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("tumblr = ?", tumblrID).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, "executing query")
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *User) UpdateFromTumblr(ctx context.Context, ex Execer, tumblrID, tumblrUsername string) error {
|
|
||||||
sql, args, err := sq.Update("users").
|
|
||||||
Set("tumblr", tumblrID).
|
|
||||||
Set("tumblr_username", tumblrUsername).
|
|
||||||
Where("id = ?", u.ID).
|
|
||||||
ToSql()
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "building sql")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = ex.Exec(ctx, sql, args...)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "executing query")
|
|
||||||
}
|
|
||||||
|
|
||||||
u.Tumblr = &tumblrID
|
|
||||||
u.TumblrUsername = &tumblrUsername
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *User) UnlinkTumblr(ctx context.Context, ex Execer) error {
|
|
||||||
sql, args, err := sq.Update("users").
|
|
||||||
Set("tumblr", nil).
|
|
||||||
Set("tumblr_username", nil).
|
|
||||||
Where("id = ?", u.ID).
|
|
||||||
ToSql()
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "building sql")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = ex.Exec(ctx, sql, args...)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "executing query")
|
|
||||||
}
|
|
||||||
|
|
||||||
u.Tumblr = nil
|
|
||||||
u.TumblrUsername = nil
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GoogleUser fetches a user by Google user ID.
|
|
||||||
func (db *DB) GoogleUser(ctx context.Context, googleID string) (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("google = ?", googleID).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, "executing query")
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *User) UpdateFromGoogle(ctx context.Context, ex Execer, googleID, googleUsername string) error {
|
|
||||||
sql, args, err := sq.Update("users").
|
|
||||||
Set("google", googleID).
|
|
||||||
Set("google_username", googleUsername).
|
|
||||||
Where("id = ?", u.ID).
|
|
||||||
ToSql()
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "building sql")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = ex.Exec(ctx, sql, args...)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "executing query")
|
|
||||||
}
|
|
||||||
|
|
||||||
u.Google = &googleID
|
|
||||||
u.GoogleUsername = &googleUsername
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *User) UnlinkGoogle(ctx context.Context, ex Execer) error {
|
|
||||||
sql, args, err := sq.Update("users").
|
|
||||||
Set("google", nil).
|
|
||||||
Set("google_username", nil).
|
|
||||||
Where("id = ?", u.ID).
|
|
||||||
ToSql()
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "building sql")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = ex.Exec(ctx, sql, args...)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "executing query")
|
|
||||||
}
|
|
||||||
|
|
||||||
u.Google = nil
|
|
||||||
u.GoogleUsername = nil
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// User gets a user by ID.
|
// User gets a user by ID.
|
||||||
func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) {
|
func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) {
|
||||||
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
|
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
|
||||||
|
@ -495,26 +258,6 @@ func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) {
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserBySnowflake gets a user by their snowflake ID.
|
|
||||||
func (db *DB) UserBySnowflake(ctx context.Context, id common.UserID) (u User, err error) {
|
|
||||||
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
|
|
||||||
From("users").Where("snowflake_id = ?", id).ToSql()
|
|
||||||
if err != nil {
|
|
||||||
return u, errors.Wrap(err, "building sql")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = pgxscan.Get(ctx, db, &u, sql, args...)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Cause(err) == pgx.ErrNoRows {
|
|
||||||
return u, ErrUserNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return u, errors.Wrap(err, "getting user from db")
|
|
||||||
}
|
|
||||||
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Username gets a user by username.
|
// Username gets a user by username.
|
||||||
func (db *DB) Username(ctx context.Context, name string) (u User, err error) {
|
func (db *DB) Username(ctx context.Context, name string) (u User, err error) {
|
||||||
sql, args, err := sq.Select("*").From("users").Where("username = ?", name).ToSql()
|
sql, args, err := sq.Select("*").From("users").Where("username = ?", name).ToSql()
|
||||||
|
@ -534,28 +277,9 @@ func (db *DB) Username(ctx context.Context, name string) (u User, err error) {
|
||||||
return u, nil
|
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.
|
// UsernameTaken checks if the given username is already taken.
|
||||||
func (db *DB) UsernameTaken(ctx context.Context, username string) (valid, taken bool, err error) {
|
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
|
return false, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -565,8 +289,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.
|
// 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 {
|
func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName string) error {
|
||||||
if err := UsernameValid(newName); err != nil {
|
if !usernameRegex.MatchString(newName) {
|
||||||
return err
|
return ErrInvalidUsername
|
||||||
}
|
}
|
||||||
|
|
||||||
sql, args, err := sq.Update("users").Set("username", newName).Where("id = ?", id).ToSql()
|
sql, args, err := sq.Update("users").Set("username", newName).Where("id = ?", id).ToSql()
|
||||||
|
@ -579,7 +303,7 @@ func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName
|
||||||
pge := &pgconn.PgError{}
|
pge := &pgconn.PgError{}
|
||||||
if errors.As(err, &pge) {
|
if errors.As(err, &pge) {
|
||||||
// unique constraint violation
|
// unique constraint violation
|
||||||
if pge.Code == uniqueViolation {
|
if pge.Code == "23505" {
|
||||||
return ErrUsernameTaken
|
return ErrUsernameTaken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -593,13 +317,10 @@ func (db *DB) UpdateUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx pgx.Tx, id xid.ID,
|
tx pgx.Tx, id xid.ID,
|
||||||
displayName, bio *string,
|
displayName, bio *string,
|
||||||
memberTitle *string, listPrivate *bool,
|
|
||||||
links *[]string,
|
links *[]string,
|
||||||
avatar *string,
|
avatar *string,
|
||||||
timezone *string,
|
|
||||||
customPreferences *CustomPreferences,
|
|
||||||
) (u User, err error) {
|
) (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 {
|
||||||
sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql()
|
sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return u, errors.Wrap(err, "building sql")
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
@ -628,29 +349,9 @@ func (db *DB) UpdateUser(
|
||||||
builder = builder.Set("bio", *bio)
|
builder = builder.Set("bio", *bio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if memberTitle != nil {
|
|
||||||
if *memberTitle == "" {
|
|
||||||
builder = builder.Set("member_title", nil)
|
|
||||||
} else {
|
|
||||||
builder = builder.Set("member_title", *memberTitle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if timezone != nil {
|
|
||||||
if *timezone == "" {
|
|
||||||
builder = builder.Set("timezone", nil)
|
|
||||||
} else {
|
|
||||||
builder = builder.Set("timezone", *timezone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if links != nil {
|
if links != nil {
|
||||||
builder = builder.Set("links", *links)
|
builder = builder.Set("links", *links)
|
||||||
}
|
}
|
||||||
if listPrivate != nil {
|
|
||||||
builder = builder.Set("list_private", *listPrivate)
|
|
||||||
}
|
|
||||||
if customPreferences != nil {
|
|
||||||
builder = builder.Set("custom_preferences", *customPreferences)
|
|
||||||
}
|
|
||||||
|
|
||||||
if avatar != nil {
|
if avatar != nil {
|
||||||
if *avatar == "" {
|
if *avatar == "" {
|
||||||
|
@ -689,23 +390,6 @@ func (db *DB) DeleteUser(ctx context.Context, tx pgx.Tx, id xid.ID, selfDelete b
|
||||||
return nil
|
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 {
|
func (db *DB) UndoDeleteUser(ctx context.Context, id xid.ID) error {
|
||||||
sql, args, err := sq.Update("users").
|
sql, args, err := sq.Update("users").
|
||||||
Set("deleted_at", nil).
|
Set("deleted_at", nil).
|
||||||
|
@ -808,7 +492,7 @@ func (db *DB) CleanUser(ctx context.Context, id xid.ID) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
members, err := db.UserMembers(ctx, u.ID, true)
|
members, err := db.UserMembers(ctx, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "getting members")
|
return errors.Wrap(err, "getting members")
|
||||||
}
|
}
|
||||||
|
@ -825,24 +509,3 @@ func (db *DB) CleanUser(ctx context.Context, id xid.ID) error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const inactiveUsersSQL = `select id, snowflake_id from users
|
|
||||||
where last_active < now() - '30 days'::interval
|
|
||||||
and display_name is null and bio is null and timezone is null
|
|
||||||
and links is null and avatar is null and member_title is null
|
|
||||||
and names = '[]' and pronouns = '[]'
|
|
||||||
and (select count(m.id) from members m where user_id = users.id) = 0
|
|
||||||
and (select count(f.id) from user_fields f where user_id = users.id) = 0;`
|
|
||||||
|
|
||||||
// InactiveUsers gets the list of inactive users from the database.
|
|
||||||
// "Inactive" is defined as:
|
|
||||||
// - not logged in for 30 days or more
|
|
||||||
// - no display name, bio, avatar, names, pronouns, profile links, or profile fields
|
|
||||||
// - no members
|
|
||||||
func (db *DB) InactiveUsers(ctx context.Context, tx pgx.Tx) (us []User, err error) {
|
|
||||||
err = pgxscan.Select(ctx, tx, &us, inactiveUsersSQL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "executing query")
|
|
||||||
}
|
|
||||||
return us, nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"emperror.dev/errors"
|
|
||||||
"github.com/rs/xid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserSettings struct {
|
|
||||||
ReadChangelog string `json:"read_changelog"`
|
|
||||||
ReadSettingsNotice string `json:"read_settings_notice"`
|
|
||||||
ReadGlobalNotice int `json:"read_global_notice"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) UpdateUserSettings(ctx context.Context, id xid.ID, us UserSettings) error {
|
|
||||||
sql, args, err := sq.Update("users").Set("settings", us).Where("id = ?", id).ToSql()
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "building sql")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.Exec(ctx, sql, args...)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "executing query")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -13,8 +13,8 @@ import (
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
|
@ -118,11 +118,6 @@ func (s *server) doExport(u db.User) {
|
||||||
|
|
||||||
log.Debugf("[%v] starting export of user", u.ID)
|
log.Debugf("[%v] starting export of user", u.ID)
|
||||||
|
|
||||||
jsonBuffer := new(bytes.Buffer)
|
|
||||||
encoder := json.NewEncoder(jsonBuffer)
|
|
||||||
encoder.SetEscapeHTML(false)
|
|
||||||
encoder.SetIndent("", " ")
|
|
||||||
|
|
||||||
outBuffer := new(bytes.Buffer)
|
outBuffer := new(bytes.Buffer)
|
||||||
zw := zip.NewWriter(outBuffer)
|
zw := zip.NewWriter(outBuffer)
|
||||||
defer zw.Close()
|
defer zw.Close()
|
||||||
|
@ -141,28 +136,19 @@ func (s *server) doExport(u db.User) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debugf("[%v] getting user warnings", u.ID)
|
|
||||||
|
|
||||||
warnings, err := s.DB.Warnings(ctx, u.ID, false)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("[%v] getting warnings: %v", u.ID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debugf("[%v] writing user json", u.ID)
|
log.Debugf("[%v] writing user json", u.ID)
|
||||||
|
|
||||||
err = encoder.Encode(dbUserToExport(u, fields, warnings))
|
ub, err := json.Marshal(dbUserToExport(u, fields))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("[%v] marshaling user: %v", u.ID, err)
|
log.Errorf("[%v] marshaling user: %v", u.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = io.Copy(w, jsonBuffer)
|
_, err = w.Write(ub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("[%v] writing user: %v", u.ID, err)
|
log.Errorf("[%v] writing user: %v", u.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonBuffer.Reset()
|
|
||||||
|
|
||||||
if u.Avatar != nil {
|
if u.Avatar != nil {
|
||||||
log.Debugf("[%v] getting user avatar", u.ID)
|
log.Debugf("[%v] getting user avatar", u.ID)
|
||||||
|
@ -189,7 +175,7 @@ func (s *server) doExport(u db.User) {
|
||||||
log.Debugf("[%v] exported user avatar", u.ID)
|
log.Debugf("[%v] exported user avatar", u.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
members, err := s.DB.UserMembers(ctx, u.ID, true)
|
members, err := s.DB.UserMembers(ctx, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("[%v] getting user members: %v", u.ID, err)
|
log.Errorf("[%v] getting user members: %v", u.ID, err)
|
||||||
return
|
return
|
||||||
|
@ -210,18 +196,17 @@ func (s *server) doExport(u db.User) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = encoder.Encode(dbMemberToExport(m, fields))
|
mb, err := json.Marshal(dbMemberToExport(m, fields))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("[%v] marshaling member %v: %v", u.ID, m.ID, err)
|
log.Errorf("[%v] marshaling member %v: %v", u.ID, m.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = io.Copy(w, jsonBuffer)
|
_, err = w.Write(mb)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("[%v] writing member %v json: %v", u.ID, m.ID, err)
|
log.Errorf("[%v] writing member %v json: %v", u.ID, m.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonBuffer.Reset()
|
|
||||||
|
|
||||||
if m.Avatar != nil {
|
if m.Avatar != nil {
|
||||||
log.Debugf("[%v] getting member %v avatar", u.ID, m.ID)
|
log.Debugf("[%v] getting member %v avatar", u.ID, m.ID)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package exporter
|
package exporter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -17,45 +17,25 @@ type userExport struct {
|
||||||
Pronouns []db.PronounEntry `json:"pronouns"`
|
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||||
Fields []db.Field `json:"fields"`
|
Fields []db.Field `json:"fields"`
|
||||||
|
|
||||||
Fediverse *string `json:"fediverse"`
|
|
||||||
FediverseUsername *string `json:"fediverse_username"`
|
|
||||||
FediverseInstance *string `json:"fediverse_instance"`
|
|
||||||
|
|
||||||
Discord *string `json:"discord"`
|
Discord *string `json:"discord"`
|
||||||
DiscordUsername *string `json:"discord_username"`
|
DiscordUsername *string `json:"discord_username"`
|
||||||
|
|
||||||
Tumblr *string `json:"tumblr"`
|
|
||||||
TumblrUsername *string `json:"tumblr_username"`
|
|
||||||
|
|
||||||
Google *string `json:"google"`
|
|
||||||
GoogleUsername *string `json:"google_username"`
|
|
||||||
|
|
||||||
MaxInvites int `json:"max_invites"`
|
MaxInvites int `json:"max_invites"`
|
||||||
|
|
||||||
Warnings []db.Warning `json:"warnings"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func dbUserToExport(u db.User, fields []db.Field, warnings []db.Warning) userExport {
|
func dbUserToExport(u db.User, fields []db.Field) userExport {
|
||||||
return userExport{
|
return userExport{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
DisplayName: u.DisplayName,
|
DisplayName: u.DisplayName,
|
||||||
Bio: u.Bio,
|
Bio: u.Bio,
|
||||||
Links: db.NotNull(u.Links),
|
Links: u.Links,
|
||||||
Names: db.NotNull(u.Names),
|
Names: u.Names,
|
||||||
Pronouns: db.NotNull(u.Pronouns),
|
Pronouns: u.Pronouns,
|
||||||
Fields: db.NotNull(fields),
|
Fields: fields,
|
||||||
Discord: u.Discord,
|
Discord: u.Discord,
|
||||||
DiscordUsername: u.DiscordUsername,
|
DiscordUsername: u.DiscordUsername,
|
||||||
Tumblr: u.Tumblr,
|
|
||||||
TumblrUsername: u.TumblrUsername,
|
|
||||||
Google: u.Google,
|
|
||||||
GoogleUsername: u.GoogleUsername,
|
|
||||||
MaxInvites: u.MaxInvites,
|
MaxInvites: u.MaxInvites,
|
||||||
Fediverse: u.Fediverse,
|
|
||||||
FediverseUsername: u.FediverseUsername,
|
|
||||||
FediverseInstance: u.FediverseInstance,
|
|
||||||
Warnings: db.NotNull(warnings),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,7 +48,6 @@ type memberExport struct {
|
||||||
Names []db.FieldEntry `json:"names"`
|
Names []db.FieldEntry `json:"names"`
|
||||||
Pronouns []db.PronounEntry `json:"pronouns"`
|
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||||
Fields []db.Field `json:"fields"`
|
Fields []db.Field `json:"fields"`
|
||||||
Unlisted bool `json:"unlisted"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func dbMemberToExport(m db.Member, fields []db.Field) memberExport {
|
func dbMemberToExport(m db.Member, fields []db.Field) memberExport {
|
||||||
|
@ -77,10 +56,9 @@ func dbMemberToExport(m db.Member, fields []db.Field) memberExport {
|
||||||
Name: m.Name,
|
Name: m.Name,
|
||||||
DisplayName: m.DisplayName,
|
DisplayName: m.DisplayName,
|
||||||
Bio: m.Bio,
|
Bio: m.Bio,
|
||||||
Links: db.NotNull(m.Links),
|
Links: m.Links,
|
||||||
Names: db.NotNull(m.Names),
|
Names: m.Names,
|
||||||
Pronouns: db.NotNull(m.Pronouns),
|
Pronouns: m.Pronouns,
|
||||||
Fields: db.NotNull(fields),
|
Fields: fields,
|
||||||
Unlisted: m.Unlisted,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -7,11 +7,9 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"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/go-chi/render"
|
||||||
_ "github.com/joho/godotenv/autoload"
|
_ "github.com/joho/godotenv/autoload"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
|
@ -24,25 +22,6 @@ var Command = &cli.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(c *cli.Context) error {
|
func run(c *cli.Context) error {
|
||||||
// initialize sentry
|
|
||||||
if dsn := os.Getenv("SENTRY_DSN"); dsn != "" {
|
|
||||||
// We don't need to check the error here--it's fine if no DSN is set.
|
|
||||||
_ = sentry.Init(sentry.ClientOptions{
|
|
||||||
Dsn: dsn,
|
|
||||||
Debug: os.Getenv("DEBUG") == "true",
|
|
||||||
Release: server.Tag,
|
|
||||||
EnableTracing: os.Getenv("SENTRY_TRACING") == "true",
|
|
||||||
TracesSampleRate: 0.05,
|
|
||||||
ProfilesSampleRate: 0.05,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// set vips log level to WARN, else it will spam logs on info level
|
|
||||||
vips.LoggingSettings(nil, vips.LogLevelWarning)
|
|
||||||
|
|
||||||
vips.Startup(nil)
|
|
||||||
defer vips.Shutdown()
|
|
||||||
|
|
||||||
port := ":" + os.Getenv("PORT")
|
port := ":" + os.Getenv("PORT")
|
||||||
|
|
||||||
s, err := server.New()
|
s, err := server.New()
|
||||||
|
@ -76,8 +55,9 @@ func run(c *cli.Context) error {
|
||||||
return nil
|
return nil
|
||||||
case err := <-e:
|
case err := <-e:
|
||||||
log.Fatalf("Error running server: %v", err)
|
log.Fatalf("Error running server: %v", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const MaxContentLength = 2 * 1024 * 1024
|
const MaxContentLength = 2 * 1024 * 1024
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -1,30 +1,26 @@
|
||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/auth"
|
"codeberg.org/u1f320/pronouns.cc/backend/routes/auth"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/member"
|
"codeberg.org/u1f320/pronouns.cc/backend/routes/bot"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/meta"
|
"codeberg.org/u1f320/pronouns.cc/backend/routes/member"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/mod"
|
"codeberg.org/u1f320/pronouns.cc/backend/routes/meta"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/user"
|
"codeberg.org/u1f320/pronouns.cc/backend/routes/mod"
|
||||||
user2 "codeberg.org/pronounscc/pronouns.cc/backend/routes/v2/user"
|
"codeberg.org/u1f320/pronouns.cc/backend/routes/user"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
_ "embed"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// mountRoutes mounts all API routes on the server's router.
|
// mountRoutes mounts all API routes on the server's router.
|
||||||
// they are all mounted under /v1/
|
// they are all mounted under /v1/
|
||||||
func mountRoutes(s *server.Server) {
|
func mountRoutes(s *server.Server) {
|
||||||
|
// future-proofing for API versions
|
||||||
s.Router.Route("/v1", func(r chi.Router) {
|
s.Router.Route("/v1", func(r chi.Router) {
|
||||||
auth.Mount(s, r)
|
auth.Mount(s, r)
|
||||||
user.Mount(s, r)
|
user.Mount(s, r)
|
||||||
member.Mount(s, r)
|
member.Mount(s, r)
|
||||||
|
bot.Mount(s, r)
|
||||||
meta.Mount(s, r)
|
meta.Mount(s, r)
|
||||||
mod.Mount(s, r)
|
mod.Mount(s, r)
|
||||||
})
|
})
|
||||||
|
|
||||||
s.Router.Route("/v2", func(r chi.Router) {
|
|
||||||
user2.Mount(s, r)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,13 +5,12 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/mediocregopher/radix/v4"
|
"github.com/mediocregopher/radix/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
@ -28,7 +27,7 @@ var discordOAuthConfig = oauth2.Config{
|
||||||
Scopes: []string{"identify"},
|
Scopes: []string{"identify"},
|
||||||
}
|
}
|
||||||
|
|
||||||
type oauthCallbackRequest struct {
|
type discordOauthCallbackRequest struct {
|
||||||
CallbackDomain string `json:"callback_domain"`
|
CallbackDomain string `json:"callback_domain"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
|
@ -43,7 +42,6 @@ type discordCallbackResponse struct {
|
||||||
Discord string `json:"discord,omitempty"` // username, for UI purposes
|
Discord string `json:"discord,omitempty"` // username, for UI purposes
|
||||||
Ticket string `json:"ticket,omitempty"`
|
Ticket string `json:"ticket,omitempty"`
|
||||||
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
||||||
RequireCaptcha bool `json:"require_captcha"`
|
|
||||||
|
|
||||||
IsDeleted bool `json:"is_deleted"`
|
IsDeleted bool `json:"is_deleted"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
@ -54,7 +52,7 @@ type discordCallbackResponse struct {
|
||||||
func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
decoded, err := Decode[oauthCallbackRequest](r)
|
decoded, err := Decode[discordOauthCallbackRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
}
|
}
|
||||||
|
@ -62,7 +60,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
// if the state can't be validated, return
|
// if the state can't be validated, return
|
||||||
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "validating state")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.APIError{Code: server.ErrInvalidState}
|
return server.APIError{Code: server.ErrInvalidState}
|
||||||
|
@ -80,7 +78,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
dg, _ := discordgo.New(token.Type() + " " + token.AccessToken)
|
dg, _ := discordgo.New(token.Type() + " " + token.AccessToken)
|
||||||
du, err := dg.User("@me")
|
du, err := dg.User("@me")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "getting discord user")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := s.DB.DiscordUser(ctx, du.ID)
|
u, err := s.DB.DiscordUser(ctx, du.ID)
|
||||||
|
@ -91,7 +89,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.saveUndeleteToken(ctx, u.ID, token)
|
err = s.saveUndeleteToken(ctx, u.ID, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("saving undelete token: %v", err)
|
log.Errorf("saving undelete token: %v", err)
|
||||||
return errors.Wrap(err, "saving undelete token")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, discordCallbackResponse{
|
render.JSON(w, r, discordCallbackResponse{
|
||||||
|
@ -115,11 +113,11 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
tokenID := xid.New()
|
tokenID := xid.New()
|
||||||
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "creating token")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// save token to database
|
// save token to database
|
||||||
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
_, err = s.DB.SaveToken(ctx, u.ID, tokenID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "saving token to database")
|
return errors.Wrap(err, "saving token to database")
|
||||||
}
|
}
|
||||||
|
@ -138,7 +136,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
} else if err != db.ErrUserNotFound { // internal error
|
} else if err != db.ErrUserNotFound { // internal error
|
||||||
return errors.Wrap(err, "getting user")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Discord info in Redis
|
// no user found, so save a ticket + save their Discord info in Redis
|
||||||
|
@ -146,7 +144,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600")
|
err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting Discord user for ticket %q: %v", ticket, err)
|
log.Errorf("setting Discord user for ticket %q: %v", ticket, err)
|
||||||
return errors.Wrap(err, "caching discord user for ticket")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, discordCallbackResponse{
|
render.JSON(w, r, discordCallbackResponse{
|
||||||
|
@ -154,7 +152,6 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
Discord: du.String(),
|
Discord: du.String(),
|
||||||
Ticket: ticket,
|
Ticket: ticket,
|
||||||
RequireInvite: s.RequireInvite,
|
RequireInvite: s.RequireInvite,
|
||||||
RequireCaptcha: s.hcaptchaSecret != "",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -170,8 +167,8 @@ func (s *Server) discordLink(w http.ResponseWriter, r *http.Request) error {
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
// only site tokens can be used for this endpoint
|
// only site tokens can be used for this endpoint
|
||||||
if claims.APIToken {
|
if claims.APIToken || !claims.TokenWrite {
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
return server.APIError{Code: server.ErrInvalidToken}
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := Decode[linkRequest](r)
|
req, err := Decode[linkRequest](r)
|
||||||
|
@ -196,11 +193,6 @@ func (s *Server) discordLink(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrInvalidTicket}
|
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)
|
err = u.UpdateFromDiscord(ctx, s.DB, du)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "updating user from discord")
|
return errors.Wrap(err, "updating user from discord")
|
||||||
|
@ -221,8 +213,8 @@ func (s *Server) discordUnlink(w http.ResponseWriter, r *http.Request) error {
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
// only site tokens can be used for this endpoint
|
// only site tokens can be used for this endpoint
|
||||||
if claims.APIToken {
|
if claims.APIToken || !claims.TokenWrite {
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
return server.APIError{Code: server.ErrInvalidToken}
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := s.DB.User(ctx, claims.UserID)
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
@ -253,11 +245,10 @@ func (s *Server) discordUnlink(w http.ResponseWriter, r *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type signupRequest struct {
|
type discordSignupRequest struct {
|
||||||
Ticket string `json:"ticket"`
|
Ticket string `json:"ticket"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
InviteCode string `json:"invite_code"`
|
InviteCode string `json:"invite_code"`
|
||||||
CaptchaResponse string `json:"captcha_response"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type signupResponse struct {
|
type signupResponse struct {
|
||||||
|
@ -268,7 +259,7 @@ type signupResponse struct {
|
||||||
func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
req, err := Decode[signupRequest](r)
|
req, err := Decode[discordSignupRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
}
|
}
|
||||||
|
@ -279,7 +270,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "checking if username is taken")
|
return err
|
||||||
}
|
}
|
||||||
if !valid {
|
if !valid {
|
||||||
return server.APIError{Code: server.ErrInvalidUsername}
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
@ -292,12 +283,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "beginning transaction")
|
return errors.Wrap(err, "beginning transaction")
|
||||||
}
|
}
|
||||||
defer func() {
|
defer tx.Rollback(ctx)
|
||||||
err := tx.Rollback(ctx)
|
|
||||||
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
||||||
log.Error("rolling back transaction:", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
du := new(discordgo.User)
|
du := new(discordgo.User)
|
||||||
err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du)
|
err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du)
|
||||||
|
@ -307,19 +293,6 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrInvalidTicket}
|
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)
|
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Cause(err) == db.ErrUsernameTaken {
|
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")
|
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)
|
err = u.UpdateFromDiscord(ctx, tx, du)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "updating user from discord")
|
return errors.Wrap(err, "updating user from discord")
|
||||||
|
@ -375,7 +343,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// save token to database
|
// save token to database
|
||||||
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
_, err = s.DB.SaveToken(ctx, u.ID, tokenID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "saving token to database")
|
return errors.Wrap(err, "saving token to database")
|
||||||
}
|
}
|
|
@ -6,12 +6,11 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/mediocregopher/radix/v4"
|
"github.com/mediocregopher/radix/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
@ -31,7 +30,6 @@ type fediCallbackResponse struct {
|
||||||
Fediverse string `json:"fediverse,omitempty"` // username, for UI purposes
|
Fediverse string `json:"fediverse,omitempty"` // username, for UI purposes
|
||||||
Ticket string `json:"ticket,omitempty"`
|
Ticket string `json:"ticket,omitempty"`
|
||||||
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
||||||
RequireCaptcha bool `json:"require_captcha"`
|
|
||||||
|
|
||||||
IsDeleted bool `json:"is_deleted"`
|
IsDeleted bool `json:"is_deleted"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
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 the state can't be validated, return
|
||||||
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "validating state")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.APIError{Code: server.ErrInvalidState}
|
return server.APIError{Code: server.ErrInvalidState}
|
||||||
|
@ -112,7 +110,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
||||||
err = s.saveUndeleteToken(ctx, u.ID, token)
|
err = s.saveUndeleteToken(ctx, u.ID, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("saving undelete token: %v", err)
|
log.Errorf("saving undelete token: %v", err)
|
||||||
return errors.Wrap(err, "saving undelete token")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, fediCallbackResponse{
|
render.JSON(w, r, fediCallbackResponse{
|
||||||
|
@ -136,11 +134,11 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
||||||
tokenID := xid.New()
|
tokenID := xid.New()
|
||||||
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "creating token")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// save token to database
|
// save token to database
|
||||||
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
_, err = s.DB.SaveToken(ctx, u.ID, tokenID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "saving token to database")
|
return errors.Wrap(err, "saving token to database")
|
||||||
}
|
}
|
||||||
|
@ -159,7 +157,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
} else if err != db.ErrUserNotFound { // internal error
|
} else if err != db.ErrUserNotFound { // internal error
|
||||||
return errors.Wrap(err, "getting user")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Mastodon info in Redis
|
// no user found, so save a ticket + save their Mastodon info in Redis
|
||||||
|
@ -167,7 +165,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
||||||
err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600")
|
err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err)
|
log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err)
|
||||||
return errors.Wrap(err, "setting user for ticket")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, fediCallbackResponse{
|
render.JSON(w, r, fediCallbackResponse{
|
||||||
|
@ -175,7 +173,6 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
||||||
Fediverse: mu.Username,
|
Fediverse: mu.Username,
|
||||||
Ticket: ticket,
|
Ticket: ticket,
|
||||||
RequireInvite: s.RequireInvite,
|
RequireInvite: s.RequireInvite,
|
||||||
RequireCaptcha: s.hcaptchaSecret != "",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -192,8 +189,8 @@ func (s *Server) mastodonLink(w http.ResponseWriter, r *http.Request) error {
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
// only site tokens can be used for this endpoint
|
// only site tokens can be used for this endpoint
|
||||||
if claims.APIToken {
|
if claims.APIToken || !claims.TokenWrite {
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
return server.APIError{Code: server.ErrInvalidToken}
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := Decode[fediLinkRequest](r)
|
req, err := Decode[fediLinkRequest](r)
|
||||||
|
@ -223,11 +220,6 @@ func (s *Server) mastodonLink(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrInvalidTicket}
|
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)
|
err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "updating user from mastoAPI")
|
return errors.Wrap(err, "updating user from mastoAPI")
|
||||||
|
@ -248,8 +240,8 @@ func (s *Server) mastodonUnlink(w http.ResponseWriter, r *http.Request) error {
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
// only site tokens can be used for this endpoint
|
// only site tokens can be used for this endpoint
|
||||||
if claims.APIToken {
|
if claims.APIToken || !claims.TokenWrite {
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
return server.APIError{Code: server.ErrInvalidToken}
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := s.DB.User(ctx, claims.UserID)
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
@ -285,7 +277,6 @@ type fediSignupRequest struct {
|
||||||
Ticket string `json:"ticket"`
|
Ticket string `json:"ticket"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
InviteCode string `json:"invite_code"`
|
InviteCode string `json:"invite_code"`
|
||||||
CaptchaResponse string `json:"captcha_response"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
|
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)
|
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "checking if username is taken")
|
return err
|
||||||
}
|
}
|
||||||
if !valid {
|
if !valid {
|
||||||
return server.APIError{Code: server.ErrInvalidUsername}
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
@ -320,12 +311,7 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "beginning transaction")
|
return errors.Wrap(err, "beginning transaction")
|
||||||
}
|
}
|
||||||
defer func() {
|
defer tx.Rollback(ctx)
|
||||||
err := tx.Rollback(ctx)
|
|
||||||
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
||||||
log.Error("rolling back transaction:", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
mu := new(partialMastodonAccount)
|
mu := new(partialMastodonAccount)
|
||||||
err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu)
|
err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu)
|
||||||
|
@ -335,19 +321,6 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrInvalidTicket}
|
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)
|
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Cause(err) == db.ErrUsernameTaken {
|
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")
|
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)
|
err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "updating user from mastoAPI")
|
return errors.Wrap(err, "updating user from mastoAPI")
|
||||||
|
@ -403,7 +371,7 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// save token to database
|
// save token to database
|
||||||
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
_, err = s.DB.SaveToken(ctx, u.ID, tokenID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "saving token to database")
|
return errors.Wrap(err, "saving token to database")
|
||||||
}
|
}
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
)
|
)
|
||||||
|
|
|
@ -8,8 +8,8 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
)
|
)
|
||||||
|
@ -25,28 +25,11 @@ func (s *Server) getFediverseURL(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrBadRequest, Details: "Instance URL is empty"}
|
return server.APIError{Code: server.ErrBadRequest, Details: "Instance URL is empty"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Too many people tried using @username@fediverse.example despite the warning
|
|
||||||
if strings.Contains(instance, "@") {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest, Details: "Instance URL should only be the base URL, without username"}
|
|
||||||
}
|
|
||||||
|
|
||||||
app, err := s.DB.FediverseApp(ctx, instance)
|
app, err := s.DB.FediverseApp(ctx, instance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.noAppFediverseURL(ctx, w, r, instance)
|
return s.noAppFediverseURL(ctx, w, r, instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.Misskey() {
|
|
||||||
_, url, err := s.misskeyURL(ctx, app)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "generating misskey URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, FediResponse{
|
|
||||||
URL: url,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
state, err := s.setCSRFState(r.Context())
|
state, err := s.setCSRFState(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "setting CSRF state")
|
return errors.Wrap(err, "setting CSRF state")
|
||||||
|
@ -65,15 +48,9 @@ func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r
|
||||||
}
|
}
|
||||||
|
|
||||||
switch softwareName {
|
switch softwareName {
|
||||||
case "iceshrimp":
|
case "mastodon", "pleroma", "akkoma", "pixelfed", "calckey":
|
||||||
softwareName = "firefish"
|
|
||||||
fallthrough
|
|
||||||
case "misskey", "foundkey", "calckey", "firefish", "sharkey":
|
|
||||||
return s.noAppMisskeyURL(ctx, w, r, softwareName, instance)
|
|
||||||
case "mastodon", "pleroma", "akkoma", "incestoma", "pixelfed", "gotosocial":
|
|
||||||
case "glitchcafe", "hometown":
|
|
||||||
softwareName = "mastodon"
|
|
||||||
default:
|
default:
|
||||||
|
// sorry, misskey :( TODO: support misskey
|
||||||
return server.APIError{Code: server.ErrUnsupportedInstance}
|
return server.APIError{Code: server.ErrUnsupportedInstance}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
)
|
)
|
||||||
|
@ -32,10 +32,6 @@ func (s *Server) getInvites(w http.ResponseWriter, r *http.Request) error {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
if claims.APIToken {
|
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
|
||||||
}
|
|
||||||
|
|
||||||
is, err := s.DB.UserInvites(ctx, claims.UserID)
|
is, err := s.DB.UserInvites(ctx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "getting user invites")
|
return errors.Wrap(err, "getting user invites")
|
||||||
|
@ -58,10 +54,6 @@ func (s *Server) createInvite(w http.ResponseWriter, r *http.Request) error {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
if claims.APIToken {
|
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
|
||||||
}
|
|
||||||
|
|
||||||
inv, err := s.DB.CreateInvite(ctx, claims.UserID)
|
inv, err := s.DB.CreateInvite(ctx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == db.ErrTooManyInvites {
|
if err == db.ErrTooManyInvites {
|
|
@ -4,10 +4,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
@ -19,14 +18,10 @@ type Server struct {
|
||||||
|
|
||||||
RequireInvite bool
|
RequireInvite bool
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
|
||||||
hcaptchaSitekey string
|
|
||||||
hcaptchaSecret string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type userResponse struct {
|
type userResponse struct {
|
||||||
ID xid.ID `json:"id"`
|
ID xid.ID `json:"id"`
|
||||||
SnowflakeID common.UserID `json:"id_new"`
|
|
||||||
Username string `json:"name"`
|
Username string `json:"name"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Bio *string `json:"bio"`
|
Bio *string `json:"bio"`
|
||||||
|
@ -39,12 +34,6 @@ type userResponse struct {
|
||||||
Discord *string `json:"discord"`
|
Discord *string `json:"discord"`
|
||||||
DiscordUsername *string `json:"discord_username"`
|
DiscordUsername *string `json:"discord_username"`
|
||||||
|
|
||||||
Tumblr *string `json:"tumblr"`
|
|
||||||
TumblrUsername *string `json:"tumblr_username"`
|
|
||||||
|
|
||||||
Google *string `json:"google"`
|
|
||||||
GoogleUsername *string `json:"google_username"`
|
|
||||||
|
|
||||||
Fediverse *string `json:"fediverse"`
|
Fediverse *string `json:"fediverse"`
|
||||||
FediverseUsername *string `json:"fediverse_username"`
|
FediverseUsername *string `json:"fediverse_username"`
|
||||||
FediverseInstance *string `json:"fediverse_instance"`
|
FediverseInstance *string `json:"fediverse_instance"`
|
||||||
|
@ -53,7 +42,6 @@ type userResponse struct {
|
||||||
func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
|
func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
|
||||||
return &userResponse{
|
return &userResponse{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
SnowflakeID: u.SnowflakeID,
|
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
DisplayName: u.DisplayName,
|
DisplayName: u.DisplayName,
|
||||||
Bio: u.Bio,
|
Bio: u.Bio,
|
||||||
|
@ -64,10 +52,6 @@ func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
|
||||||
Fields: db.NotNull(fields),
|
Fields: db.NotNull(fields),
|
||||||
Discord: u.Discord,
|
Discord: u.Discord,
|
||||||
DiscordUsername: u.DiscordUsername,
|
DiscordUsername: u.DiscordUsername,
|
||||||
Tumblr: u.Tumblr,
|
|
||||||
TumblrUsername: u.TumblrUsername,
|
|
||||||
Google: u.Google,
|
|
||||||
GoogleUsername: u.GoogleUsername,
|
|
||||||
Fediverse: u.Fediverse,
|
Fediverse: u.Fediverse,
|
||||||
FediverseUsername: u.FediverseUsername,
|
FediverseUsername: u.FediverseUsername,
|
||||||
FediverseInstance: u.FediverseInstance,
|
FediverseInstance: u.FediverseInstance,
|
||||||
|
@ -79,8 +63,6 @@ func Mount(srv *server.Server, r chi.Router) {
|
||||||
Server: srv,
|
Server: srv,
|
||||||
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
|
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
|
||||||
BaseURL: os.Getenv("BASE_URL"),
|
BaseURL: os.Getenv("BASE_URL"),
|
||||||
hcaptchaSitekey: os.Getenv("HCAPTCHA_SITEKEY"),
|
|
||||||
hcaptchaSecret: os.Getenv("HCAPTCHA_SECRET"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Route("/auth", func(r chi.Router) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
|
@ -102,20 +84,6 @@ func Mount(srv *server.Server, r chi.Router) {
|
||||||
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.discordUnlink))
|
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.discordUnlink))
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/tumblr", func(r chi.Router) {
|
|
||||||
r.Post("/callback", server.WrapHandler(s.tumblrCallback))
|
|
||||||
r.Post("/signup", server.WrapHandler(s.tumblrSignup))
|
|
||||||
r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.tumblrLink))
|
|
||||||
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.tumblrUnlink))
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Route("/google", func(r chi.Router) {
|
|
||||||
r.Post("/callback", server.WrapHandler(s.googleCallback))
|
|
||||||
r.Post("/signup", server.WrapHandler(s.googleSignup))
|
|
||||||
r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.googleLink))
|
|
||||||
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.googleUnlink))
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Route("/mastodon", func(r chi.Router) {
|
r.Route("/mastodon", func(r chi.Router) {
|
||||||
r.Post("/callback", server.WrapHandler(s.mastodonCallback))
|
r.Post("/callback", server.WrapHandler(s.mastodonCallback))
|
||||||
r.Post("/signup", server.WrapHandler(s.mastodonSignup))
|
r.Post("/signup", server.WrapHandler(s.mastodonSignup))
|
||||||
|
@ -123,12 +91,6 @@ func Mount(srv *server.Server, r chi.Router) {
|
||||||
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.mastodonUnlink))
|
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.mastodonUnlink))
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/misskey", func(r chi.Router) {
|
|
||||||
r.Post("/callback", server.WrapHandler(s.misskeyCallback))
|
|
||||||
r.Post("/signup", server.WrapHandler(s.misskeySignup))
|
|
||||||
r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.misskeyLink))
|
|
||||||
})
|
|
||||||
|
|
||||||
// invite routes
|
// invite routes
|
||||||
r.With(server.MustAuth).Get("/invites", server.WrapHandler(s.getInvites))
|
r.With(server.MustAuth).Get("/invites", server.WrapHandler(s.getInvites))
|
||||||
r.With(server.MustAuth).Post("/invites", server.WrapHandler(s.createInvite))
|
r.With(server.MustAuth).Post("/invites", server.WrapHandler(s.createInvite))
|
||||||
|
@ -136,7 +98,7 @@ func Mount(srv *server.Server, r chi.Router) {
|
||||||
// tokens
|
// tokens
|
||||||
r.With(server.MustAuth).Get("/tokens", server.WrapHandler(s.getTokens))
|
r.With(server.MustAuth).Get("/tokens", server.WrapHandler(s.getTokens))
|
||||||
r.With(server.MustAuth).Post("/tokens", server.WrapHandler(s.createToken))
|
r.With(server.MustAuth).Post("/tokens", server.WrapHandler(s.createToken))
|
||||||
r.With(server.MustAuth).Delete("/tokens", server.WrapHandler(s.deleteToken))
|
r.With(server.MustAuth).Delete("/tokens/{id}", server.WrapHandler(s.deleteToken))
|
||||||
|
|
||||||
// cancel user delete
|
// cancel user delete
|
||||||
// uses a special token, so handled in the function itself
|
// uses a special token, so handled in the function itself
|
||||||
|
@ -152,9 +114,7 @@ type oauthURLsRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type oauthURLsResponse struct {
|
type oauthURLsResponse struct {
|
||||||
Discord string `json:"discord,omitempty"`
|
Discord string `json:"discord"`
|
||||||
Tumblr string `json:"tumblr,omitempty"`
|
|
||||||
Google string `json:"google,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
@ -170,25 +130,14 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "setting CSRF state")
|
return errors.Wrap(err, "setting CSRF state")
|
||||||
}
|
}
|
||||||
var resp oauthURLsResponse
|
|
||||||
|
|
||||||
if discordOAuthConfig.ClientID != "" {
|
// copy Discord config and set redirect url
|
||||||
discordCfg := discordOAuthConfig
|
discordCfg := discordOAuthConfig
|
||||||
discordCfg.RedirectURL = req.CallbackDomain + "/auth/login/discord"
|
discordCfg.RedirectURL = req.CallbackDomain + "/auth/login/discord"
|
||||||
resp.Discord = discordCfg.AuthCodeURL(state) + "&prompt=none"
|
|
||||||
}
|
|
||||||
if tumblrOAuthConfig.ClientID != "" {
|
|
||||||
tumblrCfg := tumblrOAuthConfig
|
|
||||||
tumblrCfg.RedirectURL = req.CallbackDomain + "/auth/login/tumblr"
|
|
||||||
resp.Tumblr = tumblrCfg.AuthCodeURL(state)
|
|
||||||
}
|
|
||||||
if googleOAuthConfig.ClientID != "" {
|
|
||||||
googleCfg := googleOAuthConfig
|
|
||||||
googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google"
|
|
||||||
resp.Google = googleCfg.AuthCodeURL(state) + "&prompt=select_account"
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, resp)
|
render.JSON(w, r, oauthURLsResponse{
|
||||||
|
Discord: discordCfg.AuthCodeURL(state) + "&prompt=none",
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
83
backend/routes/auth/tokens.go
Normal file
83
backend/routes/auth/tokens.go
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
|
"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/v4"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type getTokenResponse struct {
|
||||||
|
TokenID xid.ID `json:"id"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Expires time.Time `json:"expires"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbTokenToGetResponse(t db.Token) getTokenResponse {
|
||||||
|
return getTokenResponse{
|
||||||
|
TokenID: t.TokenID,
|
||||||
|
Created: t.Created,
|
||||||
|
Expires: t.Expires,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getTokens(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
tokens, err := s.DB.Tokens(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
resps := make([]getTokenResponse, len(tokens))
|
||||||
|
for i := range tokens {
|
||||||
|
resps[i] = dbTokenToGetResponse(tokens[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, resps)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteTokenResponse struct {
|
||||||
|
TokenID xid.ID `json:"id"`
|
||||||
|
Invalidated bool `json:"invalidated"`
|
||||||
|
Created time.Time `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
tokenID, err := xid.FromString(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := s.DB.InvalidateToken(ctx, claims.UserID, tokenID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return server.APIError{Code: server.ErrNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "invalidating token")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, deleteTokenResponse{
|
||||||
|
TokenID: t.TokenID,
|
||||||
|
Invalidated: t.Invalidated,
|
||||||
|
Created: t.Created,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createToken(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
// unimplemented right now
|
||||||
|
return server.APIError{Code: server.ErrForbidden}
|
||||||
|
}
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
"github.com/mediocregopher/radix/v4"
|
"github.com/mediocregopher/radix/v4"
|
||||||
|
@ -42,7 +42,7 @@ func (s *Server) cancelDelete(w http.ResponseWriter, r *http.Request) error {
|
||||||
log.Errorf("executing undelete query: %v", err)
|
log.Errorf("executing undelete query: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
render.NoContent(w, r)
|
render.JSON(w, r, map[string]any{"success": true})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,7 +67,7 @@ func (s *Server) saveUndeleteToken(ctx context.Context, userID xid.ID, token str
|
||||||
|
|
||||||
func (s *Server) getUndeleteToken(ctx context.Context, token string) (userID xid.ID, err error) {
|
func (s *Server) getUndeleteToken(ctx context.Context, token string) (userID xid.ID, err error) {
|
||||||
var idString string
|
var idString string
|
||||||
err = s.DB.Redis.Do(ctx, radix.Cmd(&idString, "GET", "undelete:"+token))
|
err = s.DB.Redis.Do(ctx, radix.Cmd(&idString, "GETDEL", "undelete:"+token))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return userID, errors.Wrap(err, "getting undelete key")
|
return userID, errors.Wrap(err, "getting undelete key")
|
||||||
}
|
}
|
||||||
|
@ -76,11 +76,6 @@ func (s *Server) getUndeleteToken(ctx context.Context, token string) (userID xid
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return userID, errors.Wrap(err, "parsing ID")
|
return userID, errors.Wrap(err, "parsing ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "undelete:"+token))
|
|
||||||
if err != nil {
|
|
||||||
return userID, errors.Wrap(err, "deleting undelete key")
|
|
||||||
}
|
|
||||||
return userID, nil
|
return userID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,6 +104,6 @@ func (s *Server) forceDelete(w http.ResponseWriter, r *http.Request) error {
|
||||||
return errors.Wrap(err, "deleting user")
|
return errors.Wrap(err, "deleting user")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.NoContent(w, r)
|
render.JSON(w, r, map[string]any{"success": true})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
183
backend/routes/bot/bot.go
Normal file
183
backend/routes/bot/bot.go
Normal 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: uint64(discordgo.MessageFlagsEphemeral),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
|
@ -5,13 +5,11 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type CreateMemberRequest struct {
|
type CreateMemberRequest struct {
|
||||||
|
@ -29,10 +27,6 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
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)
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "getting user")
|
return errors.Wrap(err, "getting user")
|
||||||
|
@ -81,38 +75,19 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
||||||
if !db.MemberNameValid(cmr.Name) {
|
if !db.MemberNameValid(cmr.Name) {
|
||||||
return server.APIError{
|
return server.APIError{
|
||||||
Code: server.ErrBadRequest,
|
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: @, \\, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, ,",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if common.StringLength(&cmr.Name) > db.MaxMemberNameLength {
|
if err := validateSlicePtr("name", &cmr.Names); err != nil {
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxMemberNameLength, common.StringLength(&cmr.Name)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if common.StringLength(cmr.DisplayName) > db.MaxDisplayNameLength {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, common.StringLength(cmr.DisplayName)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if common.StringLength(&cmr.Bio) > db.MaxUserBioLength {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, common.StringLength(&cmr.Bio)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validateSlicePtr("name", &cmr.Names, u.CustomPreferences); err != nil {
|
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("pronoun", &cmr.Pronouns, u.CustomPreferences); err != nil {
|
if err := validateSlicePtr("pronoun", &cmr.Pronouns); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("field", &cmr.Fields, u.CustomPreferences); err != nil {
|
if err := validateSlicePtr("field", &cmr.Fields); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,12 +95,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "starting transaction")
|
return errors.Wrap(err, "starting transaction")
|
||||||
}
|
}
|
||||||
defer func() {
|
defer tx.Rollback(ctx)
|
||||||
err := tx.Rollback(ctx)
|
|
||||||
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
||||||
log.Error("rolling back transaction:", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links)
|
m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -133,14 +103,14 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
||||||
return server.APIError{Code: server.ErrMemberNameInUse}
|
return server.APIError{Code: server.ErrMemberNameInUse}
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors.Wrap(err, "creating member")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// set names, pronouns, fields
|
// set names, pronouns, fields
|
||||||
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns))
|
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, cmr.Names, cmr.Pronouns)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting names and pronouns for member %v: %v", m.ID, err)
|
log.Errorf("setting names and pronouns for member %v: %v", m.ID, err)
|
||||||
return errors.Wrap(err, "setting names/pronouns")
|
return err
|
||||||
}
|
}
|
||||||
m.Names = cmr.Names
|
m.Names = cmr.Names
|
||||||
m.Pronouns = cmr.Pronouns
|
m.Pronouns = cmr.Pronouns
|
||||||
|
@ -148,7 +118,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
||||||
err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields)
|
err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting fields for member %v: %v", m.ID, err)
|
log.Errorf("setting fields for member %v: %v", m.ID, err)
|
||||||
return errors.Wrap(err, "setting fields")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if cmr.Avatar != "" {
|
if cmr.Avatar != "" {
|
||||||
|
@ -167,13 +137,13 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Errorf("converting member avatar: %v", err)
|
log.Errorf("converting member avatar: %v", err)
|
||||||
return errors.Wrap(err, "converting avatar")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
|
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("uploading member avatar: %v", err)
|
log.Errorf("uploading member avatar: %v", err)
|
||||||
return errors.Wrap(err, "uploading avatar")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar)
|
err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar)
|
||||||
|
@ -182,29 +152,22 @@ 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)
|
err = tx.Commit(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "committing transaction")
|
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))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type validator interface {
|
type validator interface {
|
||||||
Validate(custom db.CustomPreferences) string
|
Validate() string
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateSlicePtr validates a slice of validators.
|
// validateSlicePtr validates a slice of validators.
|
||||||
// If the slice is nil, a nil error is returned (assuming that the field is not required)
|
// If the slice is nil, a nil error is returned (assuming that the field is not required)
|
||||||
func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError {
|
func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
|
||||||
if slice == nil {
|
if slice == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -224,7 +187,7 @@ func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPrefe
|
||||||
|
|
||||||
// validate all fields
|
// validate all fields
|
||||||
for i, pronouns := range *slice {
|
for i, pronouns := range *slice {
|
||||||
if s := pronouns.Validate(custom); s != "" {
|
if s := pronouns.Validate(); s != "" {
|
||||||
return &server.APIError{
|
return &server.APIError{
|
||||||
Code: server.ErrBadRequest,
|
Code: server.ErrBadRequest,
|
||||||
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),
|
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),
|
52
backend/routes/member/delete_member.go
Normal file
52
backend/routes/member/delete_member.go
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
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)
|
||||||
|
|
||||||
|
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.JSON(w, r, map[string]any{"deleted": true})
|
||||||
|
return nil
|
||||||
|
}
|
132
backend/routes/member/get_member.go
Normal file
132
backend/routes/member/get_member.go
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbMemberToMember(u db.User, m db.Member, fields []db.Field) GetMemberResponse {
|
||||||
|
return 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PartialUser struct {
|
||||||
|
ID xid.ID `json:"id"`
|
||||||
|
Username string `json:"name"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Avatar *string `json:"avatar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.MemberFields(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbMemberToMember(u, m, fields))
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
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)
|
||||||
|
}
|
75
backend/routes/member/get_members.go
Normal file
75
backend/routes/member/get_members.go
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
package member
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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 memberListResponse 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func membersToMemberList(ms []db.Member) []memberListResponse {
|
||||||
|
resps := make([]memberListResponse, len(ms))
|
||||||
|
for i := range ms {
|
||||||
|
resps[i] = memberListResponse{
|
||||||
|
ID: ms[i].ID,
|
||||||
|
Name: ms[i].Name,
|
||||||
|
Bio: ms[i].Bio,
|
||||||
|
Avatar: ms[i].Avatar,
|
||||||
|
Links: db.NotNull(ms[i].Links),
|
||||||
|
Names: db.NotNull(ms[i].Names),
|
||||||
|
Pronouns: db.NotNull(ms[i].Pronouns),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resps
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getUserMembers(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}
|
||||||
|
}
|
||||||
|
|
||||||
|
ms, err := s.DB.UserMembers(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, membersToMemberList(ms))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
ms, err := s.DB.UserMembers(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, membersToMemberList(ms))
|
||||||
|
return nil
|
||||||
|
}
|
275
backend/routes/member/patch_member.go
Normal file
275
backend/routes/member/patch_member.go
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
package member
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PatchMemberRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Bio *string `json:"bio"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Links *[]string `json:"links"`
|
||||||
|
Names *[]db.FieldEntry `json:"names"`
|
||||||
|
Pronouns *[]db.PronounEntry `json:"pronouns"`
|
||||||
|
Fields *[]db.Field `json:"fields"`
|
||||||
|
Avatar *string `json:"avatar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
var req PatchMemberRequest
|
||||||
|
err = render.Decode(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate that *something* is set
|
||||||
|
if req.DisplayName == nil &&
|
||||||
|
req.Name == nil &&
|
||||||
|
req.Bio == nil &&
|
||||||
|
req.Links == nil &&
|
||||||
|
req.Fields == nil &&
|
||||||
|
req.Names == nil &&
|
||||||
|
req.Pronouns == nil &&
|
||||||
|
req.Avatar == nil {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "Data must not be empty",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trim whitespace from strings
|
||||||
|
if req.Name != nil {
|
||||||
|
*req.Name = strings.TrimSpace(*req.Name)
|
||||||
|
}
|
||||||
|
if req.DisplayName != nil {
|
||||||
|
*req.DisplayName = strings.TrimSpace(*req.DisplayName)
|
||||||
|
}
|
||||||
|
if req.Bio != nil {
|
||||||
|
*req.Bio = strings.TrimSpace(*req.Bio)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != nil && *req.Name == "" {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "Name must not be empty",
|
||||||
|
}
|
||||||
|
} else if req.Name != nil && len(*req.Name) > 100 {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "Name may not be longer than 100 characters",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate member name
|
||||||
|
if req.Name != nil {
|
||||||
|
if !db.MemberNameValid(*req.Name) {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "Member name cannot contain any of the following: @, \\, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, ,",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate display name/bio
|
||||||
|
if req.Name != nil && len(*req.Name) > db.MaxMemberNameLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxMemberNameLength, len(*req.Name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.DisplayName != nil && len(*req.DisplayName) > db.MaxDisplayNameLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, len(*req.DisplayName)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Bio != nil && len(*req.Bio) > db.MaxUserBioLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, len(*req.Bio)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate links
|
||||||
|
if req.Links != nil {
|
||||||
|
if len(*req.Links) > db.MaxUserLinksLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Too many links (max %d, current %d)", db.MaxUserLinksLength, len(*req.Links)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, link := range *req.Links {
|
||||||
|
if len(link) > db.MaxLinkLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Link %d too long (max %d, current %d)", i, db.MaxLinkLength, len(link)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("name", req.Names); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("pronoun", req.Pronouns); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("field", req.Fields); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
// update avatar
|
||||||
|
var avatarHash *string = nil
|
||||||
|
if req.Avatar != nil {
|
||||||
|
if *req.Avatar == "" {
|
||||||
|
if m.Avatar != nil {
|
||||||
|
err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("deleting member avatar: %v", err)
|
||||||
|
return errors.Wrap(err, "deleting avatar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
avatarHash = req.Avatar
|
||||||
|
} else {
|
||||||
|
webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrInvalidDataURI {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "invalid avatar data URI",
|
||||||
|
}
|
||||||
|
} else if err == db.ErrInvalidContentType {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "invalid avatar content type",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Errorf("converting member avatar: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := s.DB.WriteMemberAvatar(ctx, id, webp, jpg)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("uploading member avatar: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
avatarHash = &hash
|
||||||
|
|
||||||
|
// delete current avatar if member has one
|
||||||
|
if m.Avatar != nil {
|
||||||
|
err = s.DB.DeleteMemberAvatar(ctx, claims.UserID, *m.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("deleting existing avatar: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start transaction
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating transaction: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
m, err = s.DB.UpdateMember(ctx, tx, id, req.Name, req.DisplayName, req.Bio, req.Links, avatarHash)
|
||||||
|
if err != nil {
|
||||||
|
switch errors.Cause(err) {
|
||||||
|
case db.ErrNothingToUpdate:
|
||||||
|
case db.ErrMemberNameInUse:
|
||||||
|
return server.APIError{Code: server.ErrMemberNameInUse}
|
||||||
|
default:
|
||||||
|
log.Errorf("updating member: %v", err)
|
||||||
|
return errors.Wrap(err, "updating member in db")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Names != nil || req.Pronouns != nil {
|
||||||
|
names := m.Names
|
||||||
|
pronouns := m.Pronouns
|
||||||
|
|
||||||
|
if req.Names != nil {
|
||||||
|
names = *req.Names
|
||||||
|
}
|
||||||
|
if req.Pronouns != nil {
|
||||||
|
pronouns = *req.Pronouns
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.SetMemberNamesPronouns(ctx, tx, id, names, pronouns)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("setting names for member %v: %v", id, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Names = names
|
||||||
|
m.Pronouns = pronouns
|
||||||
|
}
|
||||||
|
|
||||||
|
var fields []db.Field
|
||||||
|
if req.Fields != nil {
|
||||||
|
err = s.DB.SetMemberFields(ctx, tx, id, *req.Fields)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("setting fields for member %v: %v", id, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fields = *req.Fields
|
||||||
|
} else {
|
||||||
|
fields, err = s.DB.MemberFields(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting fields for member %v: %v", id, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("committing transaction: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
// echo the updated member back on success
|
||||||
|
render.JSON(w, r, dbMemberToMember(u, m, fields))
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ package member
|
||||||
import (
|
import (
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
|
@ -19,7 +19,6 @@ func Mount(srv *server.Server, r chi.Router) {
|
||||||
|
|
||||||
// user-scoped member lookup (including custom urls)
|
// user-scoped member lookup (including custom urls)
|
||||||
r.Get("/users/{userRef}/members/{memberRef}", server.WrapHandler(s.getUserMember))
|
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) {
|
r.Route("/members", func(r chi.Router) {
|
||||||
// any member by ID
|
// 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).Post("/", server.WrapHandler(s.createMember))
|
||||||
r.With(server.MustAuth).Patch("/{memberRef}", server.WrapHandler(s.patchMember))
|
r.With(server.MustAuth).Patch("/{memberRef}", server.WrapHandler(s.patchMember))
|
||||||
r.With(server.MustAuth).Delete("/{memberRef}", server.WrapHandler(s.deleteMember))
|
r.With(server.MustAuth).Delete("/{memberRef}", server.WrapHandler(s.deleteMember))
|
||||||
|
|
||||||
// reroll member SID
|
|
||||||
r.With(server.MustAuth).Get("/{memberRef}/reroll", server.WrapHandler(s.rerollMemberSID))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
53
backend/routes/meta/meta.go
Normal file
53
backend/routes/meta/meta.go
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
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()
|
||||||
|
|
||||||
|
var numUsers, numMembers int64
|
||||||
|
err := s.DB.QueryRow(ctx, "SELECT COUNT(*) FROM users").Scan(&numUsers)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "querying user count")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.QueryRow(ctx, "SELECT COUNT(*) FROM members").Scan(&numMembers)
|
||||||
|
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
|
||||||
|
}
|
111
backend/routes/mod/create_report.go
Normal file
111
backend/routes/mod/create_report.go
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
package mod
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"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/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxReasonLength = 2000
|
||||||
|
|
||||||
|
type CreateReportRequest struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
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", userID, err)
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.DeletedAt != nil {
|
||||||
|
return server.APIError{Code: server.ErrUserNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateReportRequest
|
||||||
|
err = render.Decode(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Reason) > MaxReasonLength {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot exceed 2000 characters"}
|
||||||
|
}
|
||||||
|
|
||||||
|
report, err := s.DB.CreateReport(ctx, claims.UserID, u.ID, nil, req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating report for %v: %v", u.ID, err)
|
||||||
|
return errors.Wrap(err, "creating report")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, map[string]any{"created": true, "created_at": report.CreatedAt})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createMemberReport(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
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 member %v: %v", memberID, err)
|
||||||
|
return errors.Wrap(err, "getting member")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, m.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user %v: %v", m.UserID, err)
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.DeletedAt != nil {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateReportRequest
|
||||||
|
err = render.Decode(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Reason) > MaxReasonLength {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot exceed 2000 characters"}
|
||||||
|
}
|
||||||
|
|
||||||
|
report, err := s.DB.CreateReport(ctx, claims.UserID, u.ID, &m.ID, req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating report for %v: %v", m.ID, err)
|
||||||
|
return errors.Wrap(err, "creating report")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, map[string]any{"created": true, "created_at": report.CreatedAt})
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -4,8 +4,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
|
@ -4,13 +4,12 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type resolveReportRequest struct {
|
type resolveReportRequest struct {
|
||||||
|
@ -44,12 +43,7 @@ func (s *Server) resolveReport(w http.ResponseWriter, r *http.Request) error {
|
||||||
log.Errorf("creating transaction: %v", err)
|
log.Errorf("creating transaction: %v", err)
|
||||||
return errors.Wrap(err, "creating transaction")
|
return errors.Wrap(err, "creating transaction")
|
||||||
}
|
}
|
||||||
defer func() {
|
defer tx.Rollback(ctx)
|
||||||
err := tx.Rollback(ctx)
|
|
||||||
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
||||||
log.Error("rolling back transaction:", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
report, err := s.DB.Report(ctx, tx, id)
|
report, err := s.DB.Report(ctx, tx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -114,6 +108,6 @@ func (s *Server) resolveReport(w http.ResponseWriter, r *http.Request) error {
|
||||||
return errors.Wrap(err, "committing transaction")
|
return errors.Wrap(err, "committing transaction")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.NoContent(w, r)
|
render.JSON(w, r, map[string]any{"success": true})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
|
@ -3,10 +3,9 @@ package mod
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"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/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
|
@ -22,12 +21,8 @@ func Mount(srv *server.Server, r chi.Router) {
|
||||||
r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter))
|
r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter))
|
||||||
|
|
||||||
r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport))
|
r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport))
|
||||||
|
|
||||||
r.Post("/notices", server.WrapHandler(s.createNotice))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
r.With(MustAdmin).Handle("/metrics", promhttp.Handler())
|
|
||||||
|
|
||||||
r.With(server.MustAuth).Post("/users/{id}/reports", server.WrapHandler(s.createUserReport))
|
r.With(server.MustAuth).Post("/users/{id}/reports", server.WrapHandler(s.createUserReport))
|
||||||
r.With(server.MustAuth).Post("/members/{id}/reports", server.WrapHandler(s.createMemberReport))
|
r.With(server.MustAuth).Post("/members/{id}/reports", server.WrapHandler(s.createMemberReport))
|
||||||
|
|
|
@ -4,9 +4,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
@ -44,10 +44,6 @@ func (s *Server) ackWarning(w http.ResponseWriter, r *http.Request) (err error)
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
if claims.APIToken {
|
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
@ -62,6 +58,6 @@ func (s *Server) ackWarning(w http.ResponseWriter, r *http.Request) (err error)
|
||||||
return server.APIError{Code: server.ErrNotFound}
|
return server.APIError{Code: server.ErrNotFound}
|
||||||
}
|
}
|
||||||
|
|
||||||
render.NoContent(w, r)
|
render.JSON(w, r, map[string]any{"ok": true})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
|
@ -3,31 +3,24 @@ package user
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
if claims.APIToken {
|
if claims.APIToken || !claims.TokenWrite {
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
return server.APIError{Code: server.ErrMissingPermissions}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.DB.Begin(ctx)
|
tx, err := s.DB.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "creating transaction")
|
return errors.Wrap(err, "creating transaction")
|
||||||
}
|
}
|
||||||
defer func() {
|
defer tx.Rollback(ctx)
|
||||||
err := tx.Rollback(ctx)
|
|
||||||
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
||||||
log.Error("rolling back transaction:", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
err = s.DB.DeleteUser(ctx, tx, claims.UserID, true, "")
|
err = s.DB.DeleteUser(ctx, tx, claims.UserID, true, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -44,6 +37,6 @@ func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
return errors.Wrap(err, "committing transaction")
|
return errors.Wrap(err, "committing transaction")
|
||||||
}
|
}
|
||||||
|
|
||||||
render.NoContent(w, r)
|
render.JSON(w, r, map[string]any{"deleted": true})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
|
@ -4,10 +4,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/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/go-chi/render"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -15,10 +14,6 @@ func (s *Server) startExport(w http.ResponseWriter, r *http.Request) error {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
if claims.APIToken {
|
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
|
||||||
}
|
|
||||||
|
|
||||||
hasExport, err := s.DB.HasRecentExport(ctx, claims.UserID)
|
hasExport, err := s.DB.HasRecentExport(ctx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("checking if user has recent export: %v", err)
|
log.Errorf("checking if user has recent export: %v", err)
|
||||||
|
@ -48,7 +43,7 @@ func (s *Server) startExport(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render.NoContent(w, r)
|
render.JSON(w, r, map[string]any{"started": true})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,10 +56,6 @@ func (s *Server) getExport(w http.ResponseWriter, r *http.Request) error {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
if claims.APIToken {
|
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
|
||||||
}
|
|
||||||
|
|
||||||
de, err := s.DB.UserExport(ctx, claims.UserID)
|
de, err := s.DB.UserExport(ctx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == db.ErrNoExport {
|
if err == db.ErrNoExport {
|
||||||
|
@ -72,7 +63,7 @@ func (s *Server) getExport(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Errorf("getting export for user %v: %v", claims.UserID, err)
|
log.Errorf("getting export for user %v: %v", claims.UserID, err)
|
||||||
return errors.Wrap(err, "getting export")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, dataExportResponse{
|
render.JSON(w, r, dataExportResponse{
|
175
backend/routes/user/get_user.go
Normal file
175
backend/routes/user/get_user.go
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetUserResponse struct {
|
||||||
|
ID xid.ID `json:"id"`
|
||||||
|
Username 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"`
|
||||||
|
Members []PartialMember `json:"members"`
|
||||||
|
Fields []db.Field `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetMeResponse struct {
|
||||||
|
GetUserResponse
|
||||||
|
|
||||||
|
MaxInvites int `json:"max_invites"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
|
||||||
|
Discord *string `json:"discord"`
|
||||||
|
DiscordUsername *string `json:"discord_username"`
|
||||||
|
|
||||||
|
Fediverse *string `json:"fediverse"`
|
||||||
|
FediverseUsername *string `json:"fediverse_username"`
|
||||||
|
FediverseInstance *string `json:"fediverse_instance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PartialMember 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUserResponse {
|
||||||
|
resp := GetUserResponse{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
DisplayName: u.DisplayName,
|
||||||
|
Bio: u.Bio,
|
||||||
|
Avatar: u.Avatar,
|
||||||
|
Links: db.NotNull(u.Links),
|
||||||
|
Names: db.NotNull(u.Names),
|
||||||
|
Pronouns: db.NotNull(u.Pronouns),
|
||||||
|
Fields: db.NotNull(fields),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Members = make([]PartialMember, len(members))
|
||||||
|
for i := range members {
|
||||||
|
resp.Members[i] = PartialMember{
|
||||||
|
ID: members[i].ID,
|
||||||
|
Name: members[i].Name,
|
||||||
|
DisplayName: members[i].DisplayName,
|
||||||
|
Bio: members[i].Bio,
|
||||||
|
Avatar: members[i].Avatar,
|
||||||
|
Links: db.NotNull(members[i].Links),
|
||||||
|
Names: db.NotNull(members[i].Names),
|
||||||
|
Pronouns: db.NotNull(members[i].Pronouns),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
userRef := chi.URLParamFromCtx(ctx, "userRef")
|
||||||
|
|
||||||
|
if id, err := xid.FromString(userRef); err == nil {
|
||||||
|
u, err := s.DB.User(ctx, id)
|
||||||
|
if err == nil {
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error getting user fields: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := s.DB.UserMembers(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error getting user members: %v", err)
|
||||||
|
return 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.DeletedAt != nil {
|
||||||
|
return server.APIError{Code: server.ErrUserNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error getting user fields: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := s.DB.UserMembers(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error getting user members: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbUserToResponse(u, fields, members))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getMeUser(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 {
|
||||||
|
log.Errorf("Error getting user: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error getting user fields: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := s.DB.UserMembers(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error getting user members: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, GetMeResponse{
|
||||||
|
GetUserResponse: dbUserToResponse(u, fields, members),
|
||||||
|
MaxInvites: u.MaxInvites,
|
||||||
|
IsAdmin: u.IsAdmin,
|
||||||
|
Discord: u.Discord,
|
||||||
|
DiscordUsername: u.DiscordUsername,
|
||||||
|
Fediverse: u.Fediverse,
|
||||||
|
FediverseUsername: u.FediverseUsername,
|
||||||
|
FediverseInstance: u.FediverseInstance,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
280
backend/routes/user/patch_user.go
Normal file
280
backend/routes/user/patch_user.go
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PatchUserRequest struct {
|
||||||
|
Username *string `json:"username"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Bio *string `json:"bio"`
|
||||||
|
Links *[]string `json:"links"`
|
||||||
|
Names *[]db.FieldEntry `json:"names"`
|
||||||
|
Pronouns *[]db.PronounEntry `json:"pronouns"`
|
||||||
|
Fields *[]db.Field `json:"fields"`
|
||||||
|
Avatar *string `json:"avatar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchUser parses a PatchUserRequest and updates the user with the given ID.
|
||||||
|
func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
var req PatchUserRequest
|
||||||
|
err := render.Decode(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get existing user, for comparison later
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting existing user")
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate that *something* is set
|
||||||
|
if req.Username == nil &&
|
||||||
|
req.DisplayName == nil &&
|
||||||
|
req.Bio == nil &&
|
||||||
|
req.Links == nil &&
|
||||||
|
req.Fields == nil &&
|
||||||
|
req.Names == nil &&
|
||||||
|
req.Pronouns == nil &&
|
||||||
|
req.Avatar == nil {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "Data must not be empty",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate display name/bio
|
||||||
|
if req.DisplayName != nil && len(*req.DisplayName) > db.MaxDisplayNameLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, len(*req.DisplayName)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Bio != nil && len(*req.Bio) > db.MaxUserBioLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, len(*req.Bio)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate links
|
||||||
|
if req.Links != nil {
|
||||||
|
if len(*req.Links) > db.MaxUserLinksLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Too many links (max %d, current %d)", db.MaxUserLinksLength, len(*req.Links)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, link := range *req.Links {
|
||||||
|
if len(link) > db.MaxLinkLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Link %d too long (max %d, current %d)", i, db.MaxLinkLength, len(link)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("name", req.Names); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("pronoun", req.Pronouns); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("field", req.Fields); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
// update avatar
|
||||||
|
var avatarHash *string = nil
|
||||||
|
if req.Avatar != nil {
|
||||||
|
if *req.Avatar == "" {
|
||||||
|
if u.Avatar != nil {
|
||||||
|
err = s.DB.DeleteUserAvatar(ctx, u.ID, *u.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("deleting user avatar: %v", err)
|
||||||
|
return errors.Wrap(err, "deleting avatar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
avatarHash = req.Avatar
|
||||||
|
} else {
|
||||||
|
webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrInvalidDataURI {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "invalid avatar data URI",
|
||||||
|
}
|
||||||
|
} else if err == db.ErrInvalidContentType {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "invalid avatar content type",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Errorf("converting user avatar: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("uploading user avatar: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
avatarHash = &hash
|
||||||
|
|
||||||
|
// delete current avatar if user has one
|
||||||
|
if u.Avatar != nil {
|
||||||
|
err = s.DB.DeleteUserAvatar(ctx, claims.UserID, *u.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("deleting existing avatar: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start transaction
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating transaction: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// update username
|
||||||
|
if req.Username != nil && *req.Username != u.Username {
|
||||||
|
err = s.DB.UpdateUsername(ctx, tx, claims.UserID, *req.Username)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case db.ErrUsernameTaken:
|
||||||
|
return server.APIError{Code: server.ErrUsernameTaken}
|
||||||
|
case db.ErrInvalidUsername:
|
||||||
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
default:
|
||||||
|
return errors.Wrap(err, "updating username")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.Links, avatarHash)
|
||||||
|
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
|
||||||
|
log.Errorf("updating user: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Names != nil || req.Pronouns != nil {
|
||||||
|
names := u.Names
|
||||||
|
pronouns := u.Pronouns
|
||||||
|
|
||||||
|
if req.Names != nil {
|
||||||
|
names = *req.Names
|
||||||
|
}
|
||||||
|
if req.Pronouns != nil {
|
||||||
|
pronouns = *req.Pronouns
|
||||||
|
}
|
||||||
|
|
||||||
|
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 err
|
||||||
|
}
|
||||||
|
u.Names = names
|
||||||
|
u.Pronouns = pronouns
|
||||||
|
}
|
||||||
|
|
||||||
|
var fields []db.Field
|
||||||
|
if req.Fields != nil {
|
||||||
|
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 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 err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("committing transaction: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// get fedi instance name if the user has a linked fedi account
|
||||||
|
var fediInstance *string
|
||||||
|
if u.FediverseAppID != nil {
|
||||||
|
app, err := s.DB.FediverseAppByID(ctx, *u.FediverseAppID)
|
||||||
|
if err == nil {
|
||||||
|
fediInstance = &app.Instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// echo the updated user back on success
|
||||||
|
render.JSON(w, r, GetMeResponse{
|
||||||
|
GetUserResponse: dbUserToResponse(u, fields, nil),
|
||||||
|
MaxInvites: u.MaxInvites,
|
||||||
|
IsAdmin: u.IsAdmin,
|
||||||
|
Discord: u.Discord,
|
||||||
|
DiscordUsername: u.DiscordUsername,
|
||||||
|
Fediverse: u.Fediverse,
|
||||||
|
FediverseUsername: u.FediverseUsername,
|
||||||
|
FediverseInstance: fediInstance,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type validator interface {
|
||||||
|
Validate() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSlicePtr validates a slice of validators.
|
||||||
|
// If the slice is nil, a nil error is returned (assuming that the field is not required)
|
||||||
|
func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
|
||||||
|
if slice == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
max := db.MaxFields
|
||||||
|
if typ != "field" {
|
||||||
|
max = db.FieldEntriesLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// max 25 fields
|
||||||
|
if len(*slice) > max {
|
||||||
|
return &server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Too many %ss (max %d, current %d)", typ, max, len(*slice)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate all fields
|
||||||
|
for i, pronouns := range *slice {
|
||||||
|
if s := pronouns.Validate(); s != "" {
|
||||||
|
return &server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ package user
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"github.com/go-chi/chi/v5"
|
"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/start", server.WrapHandler(s.startExport))
|
||||||
r.Get("/@me/export", server.WrapHandler(s.getExport))
|
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))
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
|
@ -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
|
|
||||||
}
|
|
|
@ -1,462 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"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/render"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/mediocregopher/radix/v4"
|
|
||||||
"github.com/rs/xid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type partialMisskeyAccount struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
decoded, err := Decode[fediOauthCallbackRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
|
||||||
}
|
|
||||||
|
|
||||||
app, err := s.DB.FediverseApp(ctx, decoded.Instance)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("getting app for instance %q: %v", decoded.Instance, err)
|
|
||||||
|
|
||||||
if err == db.ErrNoInstanceApp {
|
|
||||||
// can we get here?
|
|
||||||
return server.APIError{Code: server.ErrNotFound}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
userkeyReq := struct {
|
|
||||||
AppSecret string `json:"appSecret"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
}{AppSecret: app.ClientSecret, Token: decoded.Code}
|
|
||||||
|
|
||||||
b, err := json.Marshal(userkeyReq)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "marshaling json")
|
|
||||||
}
|
|
||||||
|
|
||||||
// make me user request
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://"+decoded.Instance+"/api/auth/session/userkey", bytes.NewReader(b))
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "creating userkey request")
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "sending i request")
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
jb, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "reading i response")
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
|
||||||
log.Errorf("POST userkey for instance %q (type %v): %v", app.Instance, app.InstanceType, string(jb))
|
|
||||||
return errors.Wrap(err, "error on misskey's end")
|
|
||||||
}
|
|
||||||
|
|
||||||
var mu struct {
|
|
||||||
User partialMisskeyAccount `json:"user"`
|
|
||||||
}
|
|
||||||
err = json.Unmarshal(jb, &mu)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "unmarshaling userkey response")
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := s.DB.FediverseUser(ctx, mu.User.ID, app.ID)
|
|
||||||
if err == nil {
|
|
||||||
if u.DeletedAt != nil {
|
|
||||||
// store cancel delete token
|
|
||||||
token := undeleteToken()
|
|
||||||
err = s.saveUndeleteToken(ctx, u.ID, token)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("saving undelete token: %v", err)
|
|
||||||
return errors.Wrap(err, "saving undelete token")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, fediCallbackResponse{
|
|
||||||
HasAccount: true,
|
|
||||||
Token: token,
|
|
||||||
User: dbUserToUserResponse(u, []db.Field{}),
|
|
||||||
IsDeleted: true,
|
|
||||||
DeletedAt: u.DeletedAt,
|
|
||||||
SelfDelete: u.SelfDelete,
|
|
||||||
DeleteReason: u.DeleteReason,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = u.UpdateFromFedi(ctx, s.DB, mu.User.ID, mu.User.Username, app.ID)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("updating user %v with misskey info: %v", u.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: implement user + token permissions
|
|
||||||
tokenID := xid.New()
|
|
||||||
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "creating token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// save token to database
|
|
||||||
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "saving token to database")
|
|
||||||
}
|
|
||||||
|
|
||||||
fields, err := s.DB.UserFields(ctx, u.ID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "querying fields")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, fediCallbackResponse{
|
|
||||||
HasAccount: true,
|
|
||||||
Token: token,
|
|
||||||
User: dbUserToUserResponse(u, fields),
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
|
|
||||||
} else if err != db.ErrUserNotFound { // internal error
|
|
||||||
return errors.Wrap(err, "getting user")
|
|
||||||
}
|
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Misskey info in Redis
|
|
||||||
ticket := RandBase64(32)
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, fediCallbackResponse{
|
|
||||||
HasAccount: false,
|
|
||||||
Fediverse: mu.User.Username,
|
|
||||||
Ticket: ticket,
|
|
||||||
RequireInvite: s.RequireInvite,
|
|
||||||
RequireCaptcha: s.hcaptchaSecret != "",
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) misskeyLink(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
|
||||||
|
|
||||||
// only site tokens can be used for this endpoint
|
|
||||||
if claims.APIToken {
|
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := Decode[fediLinkRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
|
||||||
}
|
|
||||||
|
|
||||||
app, err := s.DB.FediverseApp(ctx, req.Instance)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting instance application")
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := s.DB.User(ctx, claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting user")
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.Fediverse != nil {
|
|
||||||
return server.APIError{Code: server.ErrAlreadyLinked}
|
|
||||||
}
|
|
||||||
|
|
||||||
mu := new(partialMisskeyAccount)
|
|
||||||
err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("getting misskey user for ticket: %v", err)
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
fields, err := s.DB.UserFields(ctx, u.ID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting user fields")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
req, err := Decode[fediSignupRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.RequireInvite && req.InviteCode == "" {
|
|
||||||
return server.APIError{Code: server.ErrInviteRequired}
|
|
||||||
}
|
|
||||||
|
|
||||||
app, err := s.DB.FediverseApp(ctx, req.Instance)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting instance application")
|
|
||||||
}
|
|
||||||
|
|
||||||
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "checking if username is taken")
|
|
||||||
}
|
|
||||||
if !valid {
|
|
||||||
return server.APIError{Code: server.ErrInvalidUsername}
|
|
||||||
}
|
|
||||||
if taken {
|
|
||||||
return server.APIError{Code: server.ErrUsernameTaken}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
mu := new(partialMisskeyAccount)
|
|
||||||
err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("getting misskey user for ticket: %v", err)
|
|
||||||
|
|
||||||
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 {
|
|
||||||
return server.APIError{Code: server.ErrUsernameTaken}
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.RequireInvite {
|
|
||||||
valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "checking and invalidating invite")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !valid {
|
|
||||||
return server.APIError{Code: server.ErrInviteRequired}
|
|
||||||
}
|
|
||||||
|
|
||||||
if used {
|
|
||||||
return server.APIError{Code: server.ErrInviteAlreadyUsed}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete sign up ticket
|
|
||||||
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "misskey:"+req.Ticket))
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "deleting signup ticket")
|
|
||||||
}
|
|
||||||
|
|
||||||
// commit transaction
|
|
||||||
err = tx.Commit(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "committing transaction")
|
|
||||||
}
|
|
||||||
|
|
||||||
// create token
|
|
||||||
// TODO: implement user + token permissions
|
|
||||||
tokenID := xid.New()
|
|
||||||
token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "creating token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// save token to database
|
|
||||||
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "saving token to database")
|
|
||||||
}
|
|
||||||
|
|
||||||
// return user
|
|
||||||
render.JSON(w, r, signupResponse{
|
|
||||||
User: *dbUserToUserResponse(u, nil),
|
|
||||||
Token: token,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) noAppMisskeyURL(ctx context.Context, w http.ResponseWriter, r *http.Request, softwareName, instance string) error {
|
|
||||||
log.Debugf("creating application on misskey-compatible instance %q", instance)
|
|
||||||
|
|
||||||
b, err := json.Marshal(misskeyAppRequest{
|
|
||||||
Name: "pronouns.cc (+" + s.BaseURL + ")",
|
|
||||||
Description: "pronouns.cc on " + s.BaseURL,
|
|
||||||
CallbackURL: s.BaseURL + "/auth/login/misskey/" + instance,
|
|
||||||
Permission: []string{"read:account"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("marshaling app json: %v", err)
|
|
||||||
return errors.Wrap(err, "marshaling json")
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://"+instance+"/api/app/create", bytes.NewReader(b))
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("creating POST apps request for %q: %v", instance, err)
|
|
||||||
return errors.Wrap(err, "creating POST apps request")
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("sending POST apps request for %q: %v", instance, err)
|
|
||||||
return errors.Wrap(err, "sending POST apps request")
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
jb, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("reading response for request: %v", err)
|
|
||||||
return errors.Wrap(err, "reading response")
|
|
||||||
}
|
|
||||||
|
|
||||||
var ma misskeyApp
|
|
||||||
err = json.Unmarshal(jb, &ma)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "unmarshaling misskey app")
|
|
||||||
}
|
|
||||||
|
|
||||||
app, err := s.DB.CreateFediverseApp(ctx, instance, softwareName, ma.ID, ma.Secret)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("saving app for %q: %v", instance, err)
|
|
||||||
return errors.Wrap(err, "creating app")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, url, err := s.misskeyURL(ctx, app)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("generating URL for misskey %q: %v", instance, err)
|
|
||||||
return errors.Wrap(err, "generating URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, FediResponse{
|
|
||||||
URL: url,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type misskeyAppRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Permission []string `json:"permission"`
|
|
||||||
CallbackURL string `json:"callbackUrl"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type misskeyApp struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Secret string `json:"secret"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) misskeyURL(ctx context.Context, app db.FediverseApp) (token, url string, err error) {
|
|
||||||
genSession := struct {
|
|
||||||
AppSecret string `json:"appSecret"`
|
|
||||||
}{AppSecret: app.ClientSecret}
|
|
||||||
|
|
||||||
b, err := json.Marshal(genSession)
|
|
||||||
if err != nil {
|
|
||||||
return token, url, errors.Wrap(err, "marshaling json")
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://"+app.Instance+"/api/auth/session/generate", bytes.NewReader(b))
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("creating POST session request for %q: %v", app.Instance, err)
|
|
||||||
return token, url, errors.Wrap(err, "creating POST apps request")
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("sending POST session request for %q: %v", app.Instance, err)
|
|
||||||
return token, url, errors.Wrap(err, "sending POST apps request")
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
jb, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("reading response for request: %v", err)
|
|
||||||
return token, url, errors.Wrap(err, "reading response")
|
|
||||||
}
|
|
||||||
|
|
||||||
var genSessionResp struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err = json.Unmarshal(jb, &genSessionResp)
|
|
||||||
if err != nil {
|
|
||||||
return token, url, errors.Wrap(err, "unmarshaling misskey response")
|
|
||||||
}
|
|
||||||
|
|
||||||
return genSessionResp.Token, genSessionResp.URL, nil
|
|
||||||
}
|
|
|
@ -1,392 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"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"
|
|
||||||
"github.com/go-chi/render"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/mediocregopher/radix/v4"
|
|
||||||
"github.com/rs/xid"
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
"google.golang.org/api/idtoken"
|
|
||||||
)
|
|
||||||
|
|
||||||
var googleOAuthConfig = oauth2.Config{
|
|
||||||
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
|
||||||
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
|
|
||||||
Endpoint: oauth2.Endpoint{
|
|
||||||
AuthURL: "https://accounts.google.com/o/oauth2/auth",
|
|
||||||
TokenURL: "https://oauth2.googleapis.com/token",
|
|
||||||
AuthStyle: oauth2.AuthStyleInParams,
|
|
||||||
},
|
|
||||||
Scopes: []string{"openid", "https://www.googleapis.com/auth/userinfo.email"},
|
|
||||||
}
|
|
||||||
|
|
||||||
type googleCallbackResponse struct {
|
|
||||||
HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Google will be set
|
|
||||||
|
|
||||||
Token string `json:"token,omitempty"`
|
|
||||||
User *userResponse `json:"user,omitempty"`
|
|
||||||
|
|
||||||
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"`
|
|
||||||
SelfDelete *bool `json:"self_delete,omitempty"`
|
|
||||||
DeleteReason *string `json:"delete_reason,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type partialGoogleUser struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
decoded, err := Decode[oauthCallbackRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 server.APIError{Code: server.ErrInvalidState}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := googleOAuthConfig
|
|
||||||
cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/google"
|
|
||||||
token, err := cfg.Exchange(r.Context(), decoded.Code)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("exchanging oauth code: %v", err)
|
|
||||||
return server.APIError{Code: server.ErrInvalidOAuthCode}
|
|
||||||
}
|
|
||||||
rawToken := token.Extra("id_token")
|
|
||||||
if rawToken == nil {
|
|
||||||
log.Debug("id_token was nil")
|
|
||||||
return server.APIError{Code: server.ErrInternalServerError}
|
|
||||||
}
|
|
||||||
|
|
||||||
idToken, ok := rawToken.(string)
|
|
||||||
if !ok {
|
|
||||||
log.Debug("id_token was not a string")
|
|
||||||
return server.APIError{Code: server.ErrInternalServerError}
|
|
||||||
}
|
|
||||||
payload, err := idtoken.Validate(ctx, idToken, "")
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("getting id token payload: %v", err)
|
|
||||||
return server.APIError{Code: server.ErrInternalServerError}
|
|
||||||
}
|
|
||||||
|
|
||||||
googleID, ok := payload.Claims["sub"].(string)
|
|
||||||
if !ok {
|
|
||||||
log.Debug("id_token.claims.sub was not a string")
|
|
||||||
return server.APIError{Code: server.ErrInternalServerError}
|
|
||||||
}
|
|
||||||
googleUsername, ok := payload.Claims["email"].(string)
|
|
||||||
if !ok {
|
|
||||||
log.Debug("id_token.claims.email was not a string")
|
|
||||||
return server.APIError{Code: server.ErrInternalServerError}
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := s.DB.GoogleUser(ctx, googleID)
|
|
||||||
if err == nil {
|
|
||||||
if u.DeletedAt != nil {
|
|
||||||
// store cancel delete token
|
|
||||||
token := undeleteToken()
|
|
||||||
err = s.saveUndeleteToken(ctx, u.ID, token)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("saving undelete token: %v", err)
|
|
||||||
return errors.Wrap(err, "saving undelete token")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, googleCallbackResponse{
|
|
||||||
HasAccount: true,
|
|
||||||
Token: token,
|
|
||||||
User: dbUserToUserResponse(u, []db.Field{}),
|
|
||||||
IsDeleted: true,
|
|
||||||
DeletedAt: u.DeletedAt,
|
|
||||||
SelfDelete: u.SelfDelete,
|
|
||||||
DeleteReason: u.DeleteReason,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = u.UpdateFromGoogle(ctx, s.DB, googleID, googleUsername)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("updating user %v with Google info: %v", u.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: implement user + token permissions
|
|
||||||
tokenID := xid.New()
|
|
||||||
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "creating token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// save token to database
|
|
||||||
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "saving token to database")
|
|
||||||
}
|
|
||||||
|
|
||||||
fields, err := s.DB.UserFields(ctx, u.ID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "querying fields")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, googleCallbackResponse{
|
|
||||||
HasAccount: true,
|
|
||||||
Token: token,
|
|
||||||
User: dbUserToUserResponse(u, fields),
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
|
|
||||||
} else if err != db.ErrUserNotFound { // internal error
|
|
||||||
return errors.Wrap(err, "getting user")
|
|
||||||
}
|
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Google info in Redis
|
|
||||||
ticket := RandBase64(32)
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, googleCallbackResponse{
|
|
||||||
HasAccount: false,
|
|
||||||
Google: googleUsername,
|
|
||||||
Ticket: ticket,
|
|
||||||
RequireInvite: s.RequireInvite,
|
|
||||||
RequireCaptcha: s.hcaptchaSecret != "",
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) googleLink(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
|
||||||
|
|
||||||
// only site tokens can be used for this endpoint
|
|
||||||
if claims.APIToken {
|
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := Decode[linkRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := s.DB.User(ctx, claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting user")
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.Google != nil {
|
|
||||||
return server.APIError{Code: server.ErrAlreadyLinked}
|
|
||||||
}
|
|
||||||
|
|
||||||
gu := new(partialGoogleUser)
|
|
||||||
err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("getting google user for ticket: %v", err)
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
fields, err := s.DB.UserFields(ctx, u.ID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting user fields")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) googleUnlink(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
|
||||||
|
|
||||||
// only site tokens can be used for this endpoint
|
|
||||||
if claims.APIToken {
|
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := s.DB.User(ctx, claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting user")
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.Google == nil {
|
|
||||||
return server.APIError{Code: server.ErrNotLinked}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cannot unlink last auth provider
|
|
||||||
if u.NumProviders() <= 1 {
|
|
||||||
return server.APIError{Code: server.ErrLastProvider}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = u.UnlinkGoogle(ctx, s.DB)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "updating user in db")
|
|
||||||
}
|
|
||||||
|
|
||||||
fields, err := s.DB.UserFields(ctx, u.ID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting user fields")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
req, err := Decode[signupRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.RequireInvite && req.InviteCode == "" {
|
|
||||||
return server.APIError{Code: server.ErrInviteRequired}
|
|
||||||
}
|
|
||||||
|
|
||||||
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "checking if username is taken")
|
|
||||||
}
|
|
||||||
if !valid {
|
|
||||||
return server.APIError{Code: server.ErrInvalidUsername}
|
|
||||||
}
|
|
||||||
if taken {
|
|
||||||
return server.APIError{Code: server.ErrUsernameTaken}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
gu := new(partialGoogleUser)
|
|
||||||
err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("getting google user for ticket: %v", err)
|
|
||||||
|
|
||||||
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 {
|
|
||||||
return server.APIError{Code: server.ErrUsernameTaken}
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.RequireInvite {
|
|
||||||
valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "checking and invalidating invite")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !valid {
|
|
||||||
return server.APIError{Code: server.ErrInviteRequired}
|
|
||||||
}
|
|
||||||
|
|
||||||
if used {
|
|
||||||
return server.APIError{Code: server.ErrInviteAlreadyUsed}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete sign up ticket
|
|
||||||
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "google:"+req.Ticket))
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "deleting signup ticket")
|
|
||||||
}
|
|
||||||
|
|
||||||
// commit transaction
|
|
||||||
err = tx.Commit(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "committing transaction")
|
|
||||||
}
|
|
||||||
|
|
||||||
// create token
|
|
||||||
// TODO: implement user + token permissions
|
|
||||||
tokenID := xid.New()
|
|
||||||
token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "creating token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// save token to database
|
|
||||||
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "saving token to database")
|
|
||||||
}
|
|
||||||
|
|
||||||
// return user
|
|
||||||
render.JSON(w, r, signupResponse{
|
|
||||||
User: *dbUserToUserResponse(u, nil),
|
|
||||||
Token: token,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -1,132 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
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"
|
|
||||||
"github.com/go-chi/render"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/rs/xid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type getTokenResponse struct {
|
|
||||||
TokenID xid.ID `json:"id"`
|
|
||||||
APIOnly bool `json:"api_only"`
|
|
||||||
ReadOnly bool `json:"read_only"`
|
|
||||||
Created time.Time `json:"created"`
|
|
||||||
Expires time.Time `json:"expires"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func dbTokenToGetResponse(t db.Token) getTokenResponse {
|
|
||||||
return getTokenResponse{
|
|
||||||
TokenID: t.TokenID,
|
|
||||||
APIOnly: t.APIOnly,
|
|
||||||
ReadOnly: t.ReadOnly,
|
|
||||||
Created: t.Created,
|
|
||||||
Expires: t.Expires,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) getTokens(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
|
||||||
|
|
||||||
if claims.APIToken {
|
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := s.DB.Tokens(ctx, claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting tokens")
|
|
||||||
}
|
|
||||||
|
|
||||||
resps := make([]getTokenResponse, len(tokens))
|
|
||||||
for i := range tokens {
|
|
||||||
resps[i] = dbTokenToGetResponse(tokens[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, resps)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
|
||||||
|
|
||||||
if claims.APIToken {
|
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
err = s.DB.InvalidateAllTokens(ctx, tx, claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "invalidating tokens")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.Commit(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "committing transaction")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.NoContent(w, r)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type createTokenResponse struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
TokenID xid.ID `json:"id"`
|
|
||||||
APIOnly bool `json:"api_only"`
|
|
||||||
ReadOnly bool `json:"read_only"`
|
|
||||||
Created time.Time `json:"created"`
|
|
||||||
Expires time.Time `json:"expires"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) createToken(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
|
||||||
|
|
||||||
if claims.APIToken {
|
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := s.DB.User(ctx, claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting me user")
|
|
||||||
}
|
|
||||||
|
|
||||||
readOnly := r.FormValue("read_only") == "true"
|
|
||||||
tokenID := xid.New()
|
|
||||||
tokenStr, err := s.Auth.CreateToken(claims.UserID, tokenID, u.IsAdmin, true, !readOnly)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "creating token")
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := s.DB.SaveToken(ctx, claims.UserID, tokenID, true, readOnly)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "saving token")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, createTokenResponse{
|
|
||||||
Token: tokenStr,
|
|
||||||
TokenID: t.TokenID,
|
|
||||||
APIOnly: t.APIOnly,
|
|
||||||
ReadOnly: t.ReadOnly,
|
|
||||||
Created: t.Created,
|
|
||||||
Expires: t.Expires,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -1,425 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"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"
|
|
||||||
"github.com/go-chi/render"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/mediocregopher/radix/v4"
|
|
||||||
"github.com/rs/xid"
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
)
|
|
||||||
|
|
||||||
var tumblrOAuthConfig = oauth2.Config{
|
|
||||||
ClientID: os.Getenv("TUMBLR_CLIENT_ID"),
|
|
||||||
ClientSecret: os.Getenv("TUMBLR_CLIENT_SECRET"),
|
|
||||||
Endpoint: oauth2.Endpoint{
|
|
||||||
AuthURL: "https://www.tumblr.com/oauth2/authorize",
|
|
||||||
TokenURL: "https://api.tumblr.com/v2/oauth2/token",
|
|
||||||
AuthStyle: oauth2.AuthStyleInParams,
|
|
||||||
},
|
|
||||||
Scopes: []string{"basic"},
|
|
||||||
}
|
|
||||||
|
|
||||||
type partialTumblrResponse struct {
|
|
||||||
Meta struct {
|
|
||||||
Status int `json:"status"`
|
|
||||||
Message string `json:"msg"`
|
|
||||||
} `json:"meta"`
|
|
||||||
Response struct {
|
|
||||||
User struct {
|
|
||||||
Blogs []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Primary bool `json:"primary"`
|
|
||||||
UUID string `json:"uuid"`
|
|
||||||
} `json:"blogs"`
|
|
||||||
} `json:"user"`
|
|
||||||
} `json:"response"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type tumblrUserInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type tumblrCallbackResponse struct {
|
|
||||||
HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Tumblr will be set
|
|
||||||
|
|
||||||
Token string `json:"token,omitempty"`
|
|
||||||
User *userResponse `json:"user,omitempty"`
|
|
||||||
|
|
||||||
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"`
|
|
||||||
SelfDelete *bool `json:"self_delete,omitempty"`
|
|
||||||
DeleteReason *string `json:"delete_reason,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
decoded, err := Decode[oauthCallbackRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 server.APIError{Code: server.ErrInvalidState}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := tumblrOAuthConfig
|
|
||||||
cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/tumblr"
|
|
||||||
token, err := cfg.Exchange(r.Context(), decoded.Code)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("exchanging oauth code: %v", err)
|
|
||||||
|
|
||||||
return server.APIError{Code: server.ErrInvalidOAuthCode}
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.tumblr.com/v2/user/info", nil)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "creating user/info request")
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
token.SetAuthHeader(req)
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "sending user/info request")
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
|
||||||
return errors.New("response had status code < 200 or >= 400")
|
|
||||||
}
|
|
||||||
|
|
||||||
jb, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "reading user/info response")
|
|
||||||
}
|
|
||||||
|
|
||||||
var tr partialTumblrResponse
|
|
||||||
err = json.Unmarshal(jb, &tr)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "unmarshaling user/info response")
|
|
||||||
}
|
|
||||||
|
|
||||||
var tumblrName, tumblrID string
|
|
||||||
for _, blog := range tr.Response.User.Blogs {
|
|
||||||
if blog.Primary {
|
|
||||||
tumblrName = blog.Name
|
|
||||||
tumblrID = blog.UUID
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tumblrID == "" {
|
|
||||||
return server.APIError{Code: server.ErrInternalServerError, Details: "Your Tumblr account doesn't seem to have a primary blog"}
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := s.DB.TumblrUser(ctx, tumblrID)
|
|
||||||
if err == nil {
|
|
||||||
if u.DeletedAt != nil {
|
|
||||||
// store cancel delete token
|
|
||||||
token := undeleteToken()
|
|
||||||
err = s.saveUndeleteToken(ctx, u.ID, token)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("saving undelete token: %v", err)
|
|
||||||
return errors.Wrap(err, "saving undelete token")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, tumblrCallbackResponse{
|
|
||||||
HasAccount: true,
|
|
||||||
Token: token,
|
|
||||||
User: dbUserToUserResponse(u, []db.Field{}),
|
|
||||||
IsDeleted: true,
|
|
||||||
DeletedAt: u.DeletedAt,
|
|
||||||
SelfDelete: u.SelfDelete,
|
|
||||||
DeleteReason: u.DeleteReason,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = u.UpdateFromTumblr(ctx, s.DB, tumblrID, tumblrName)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("updating user %v with Tumblr info: %v", u.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: implement user + token permissions
|
|
||||||
tokenID := xid.New()
|
|
||||||
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "creating token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// save token to database
|
|
||||||
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "saving token to database")
|
|
||||||
}
|
|
||||||
|
|
||||||
fields, err := s.DB.UserFields(ctx, u.ID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "querying fields")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, tumblrCallbackResponse{
|
|
||||||
HasAccount: true,
|
|
||||||
Token: token,
|
|
||||||
User: dbUserToUserResponse(u, fields),
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
|
|
||||||
} else if err != db.ErrUserNotFound { // internal error
|
|
||||||
return errors.Wrap(err, "getting user")
|
|
||||||
}
|
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Tumblr info in Redis
|
|
||||||
ticket := RandBase64(32)
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, tumblrCallbackResponse{
|
|
||||||
HasAccount: false,
|
|
||||||
Tumblr: tumblrName,
|
|
||||||
Ticket: ticket,
|
|
||||||
RequireInvite: s.RequireInvite,
|
|
||||||
RequireCaptcha: s.hcaptchaSecret != "",
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) tumblrLink(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
|
||||||
|
|
||||||
// only site tokens can be used for this endpoint
|
|
||||||
if claims.APIToken {
|
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := Decode[linkRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := s.DB.User(ctx, claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting user")
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.Tumblr != nil {
|
|
||||||
return server.APIError{Code: server.ErrAlreadyLinked}
|
|
||||||
}
|
|
||||||
|
|
||||||
tui := new(tumblrUserInfo)
|
|
||||||
err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("getting tumblr user for ticket: %v", err)
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
fields, err := s.DB.UserFields(ctx, u.ID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting user fields")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) tumblrUnlink(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
|
||||||
|
|
||||||
// only site tokens can be used for this endpoint
|
|
||||||
if claims.APIToken {
|
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := s.DB.User(ctx, claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting user")
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.Tumblr == nil {
|
|
||||||
return server.APIError{Code: server.ErrNotLinked}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cannot unlink last auth provider
|
|
||||||
if u.NumProviders() <= 1 {
|
|
||||||
return server.APIError{Code: server.ErrLastProvider}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = u.UnlinkTumblr(ctx, s.DB)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "updating user in db")
|
|
||||||
}
|
|
||||||
|
|
||||||
fields, err := s.DB.UserFields(ctx, u.ID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting user fields")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
req, err := Decode[signupRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.RequireInvite && req.InviteCode == "" {
|
|
||||||
return server.APIError{Code: server.ErrInviteRequired}
|
|
||||||
}
|
|
||||||
|
|
||||||
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "checking if username is taken")
|
|
||||||
}
|
|
||||||
if !valid {
|
|
||||||
return server.APIError{Code: server.ErrInvalidUsername}
|
|
||||||
}
|
|
||||||
if taken {
|
|
||||||
return server.APIError{Code: server.ErrUsernameTaken}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
tui := new(tumblrUserInfo)
|
|
||||||
err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("getting tumblr user for ticket: %v", err)
|
|
||||||
|
|
||||||
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 {
|
|
||||||
return server.APIError{Code: server.ErrUsernameTaken}
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.RequireInvite {
|
|
||||||
valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "checking and invalidating invite")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !valid {
|
|
||||||
return server.APIError{Code: server.ErrInviteRequired}
|
|
||||||
}
|
|
||||||
|
|
||||||
if used {
|
|
||||||
return server.APIError{Code: server.ErrInviteAlreadyUsed}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete sign up ticket
|
|
||||||
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "tumblr:"+req.Ticket))
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "deleting signup ticket")
|
|
||||||
}
|
|
||||||
|
|
||||||
// commit transaction
|
|
||||||
err = tx.Commit(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "committing transaction")
|
|
||||||
}
|
|
||||||
|
|
||||||
// create token
|
|
||||||
// TODO: implement user + token permissions
|
|
||||||
tokenID := xid.New()
|
|
||||||
token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "creating token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// save token to database
|
|
||||||
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "saving token to database")
|
|
||||||
}
|
|
||||||
|
|
||||||
// return user
|
|
||||||
render.JSON(w, r, signupResponse{
|
|
||||||
User: *dbUserToUserResponse(u, nil),
|
|
||||||
Token: token,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -1,96 +0,0 @@
|
||||||
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"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/render"
|
|
||||||
"github.com/rs/xid"
|
|
||||||
)
|
|
||||||
|
|
||||||
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"`
|
|
||||||
Avatar *string `json:"avatar"`
|
|
||||||
Links []string `json:"links"`
|
|
||||||
Names []db.FieldEntry `json:"names"`
|
|
||||||
Pronouns []db.PronounEntry `json:"pronouns"`
|
|
||||||
Unlisted bool `json:"unlisted"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func membersToMemberList(ms []db.Member, isSelf bool) []memberListResponse {
|
|
||||||
resps := make([]memberListResponse, len(ms))
|
|
||||||
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),
|
|
||||||
Names: db.NotNull(ms[i].Names),
|
|
||||||
Pronouns: db.NotNull(ms[i].Pronouns),
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSelf {
|
|
||||||
resps[i].Unlisted = ms[i].Unlisted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return resps
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) getUserMembers(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}
|
|
||||||
}
|
|
||||||
|
|
||||||
isSelf := false
|
|
||||||
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
|
|
||||||
isSelf = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.ListPrivate && !isSelf {
|
|
||||||
return server.APIError{Code: server.ErrMemberListPrivate}
|
|
||||||
}
|
|
||||||
|
|
||||||
ms, err := s.DB.UserMembers(ctx, u.ID, isSelf)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting members")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, membersToMemberList(ms, isSelf))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
|
||||||
|
|
||||||
ms, err := s.DB.UserMembers(ctx, claims.UserID, true)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting members")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, membersToMemberList(ms, true))
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -1,402 +0,0 @@
|
||||||
package member
|
|
||||||
|
|
||||||
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"
|
|
||||||
"emperror.dev/errors"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/render"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/rs/xid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PatchMemberRequest struct {
|
|
||||||
Name *string `json:"name"`
|
|
||||||
Bio *string `json:"bio"`
|
|
||||||
DisplayName *string `json:"display_name"`
|
|
||||||
Links *[]string `json:"links"`
|
|
||||||
Names *[]db.FieldEntry `json:"names"`
|
|
||||||
Pronouns *[]db.PronounEntry `json:"pronouns"`
|
|
||||||
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 {
|
|
||||||
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 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)
|
|
||||||
if err != nil {
|
|
||||||
if err == db.ErrMemberNotFound {
|
|
||||||
return server.APIError{Code: server.ErrMemberNotFound}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors.Wrap(err, "getting member")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrMemberNotFound}
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
}
|
|
||||||
|
|
||||||
var req PatchMemberRequest
|
|
||||||
err = render.Decode(r, &req)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate that *something* is set
|
|
||||||
if req.DisplayName == nil &&
|
|
||||||
req.Name == nil &&
|
|
||||||
req.Bio == nil &&
|
|
||||||
req.Unlisted == nil &&
|
|
||||||
req.Links == nil &&
|
|
||||||
req.Fields == nil &&
|
|
||||||
req.Names == nil &&
|
|
||||||
req.Pronouns == nil &&
|
|
||||||
req.Avatar == nil &&
|
|
||||||
req.Flags == nil {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: "Data must not be empty",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// trim whitespace from strings
|
|
||||||
if req.Name != nil {
|
|
||||||
*req.Name = strings.TrimSpace(*req.Name)
|
|
||||||
}
|
|
||||||
if req.DisplayName != nil {
|
|
||||||
*req.DisplayName = strings.TrimSpace(*req.DisplayName)
|
|
||||||
}
|
|
||||||
if req.Bio != nil {
|
|
||||||
*req.Bio = strings.TrimSpace(*req.Bio)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Name != nil && *req.Name == "" {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: "Name must not be empty",
|
|
||||||
}
|
|
||||||
} else if req.Name != nil && len(*req.Name) > 100 {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: "Name may not be longer than 100 characters",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate member name
|
|
||||||
if req.Name != nil {
|
|
||||||
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.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate display name/bio
|
|
||||||
if common.StringLength(req.Name) > db.MaxMemberNameLength {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxMemberNameLength, common.StringLength(req.Name)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if common.StringLength(req.DisplayName) > db.MaxDisplayNameLength {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, common.StringLength(req.DisplayName)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if common.StringLength(req.Bio) > db.MaxUserBioLength {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, common.StringLength(req.Name)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate links
|
|
||||||
if req.Links != nil {
|
|
||||||
if len(*req.Links) > db.MaxUserLinksLength {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("Too many links (max %d, current %d)", db.MaxUserLinksLength, len(*req.Links)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, link := range *req.Links {
|
|
||||||
if len(link) > db.MaxLinkLength {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("Link %d too long (max %d, current %d)", i, db.MaxLinkLength, len(link)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// update avatar
|
|
||||||
var avatarHash *string = nil
|
|
||||||
if req.Avatar != nil {
|
|
||||||
if *req.Avatar == "" {
|
|
||||||
if m.Avatar != nil {
|
|
||||||
err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("deleting member avatar: %v", err)
|
|
||||||
return errors.Wrap(err, "deleting avatar")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
avatarHash = req.Avatar
|
|
||||||
} else {
|
|
||||||
webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar)
|
|
||||||
if err != nil {
|
|
||||||
if err == db.ErrInvalidDataURI {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: "invalid avatar data URI",
|
|
||||||
}
|
|
||||||
} else if err == db.ErrInvalidContentType {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: "invalid avatar content type",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Errorf("converting member avatar: %v", err)
|
|
||||||
return errors.Wrap(err, "converting member avatar")
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
avatarHash = &hash
|
|
||||||
|
|
||||||
// delete current avatar if member has one
|
|
||||||
if m.Avatar != nil {
|
|
||||||
err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("deleting existing avatar: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// start transaction
|
|
||||||
tx, err := s.DB.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
m, err = s.DB.UpdateMember(ctx, tx, m.ID, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash)
|
|
||||||
if err != nil {
|
|
||||||
switch errors.Cause(err) {
|
|
||||||
case db.ErrNothingToUpdate:
|
|
||||||
case db.ErrMemberNameInUse:
|
|
||||||
return server.APIError{Code: server.ErrMemberNameInUse}
|
|
||||||
default:
|
|
||||||
log.Errorf("updating member: %v", err)
|
|
||||||
return errors.Wrap(err, "updating member in db")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Names != nil || req.Pronouns != nil {
|
|
||||||
names := m.Names
|
|
||||||
pronouns := m.Pronouns
|
|
||||||
|
|
||||||
if req.Names != nil {
|
|
||||||
names = *req.Names
|
|
||||||
}
|
|
||||||
if req.Pronouns != nil {
|
|
||||||
pronouns = *req.Pronouns
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, names, pronouns)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("setting names for member %v: %v", m.ID, err)
|
|
||||||
return errors.Wrap(err, "setting names/pronouns")
|
|
||||||
}
|
|
||||||
m.Names = names
|
|
||||||
m.Pronouns = pronouns
|
|
||||||
}
|
|
||||||
|
|
||||||
var fields []db.Field
|
|
||||||
if req.Fields != nil {
|
|
||||||
err = s.DB.SetMemberFields(ctx, tx, m.ID, *req.Fields)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("setting fields for member %v: %v", m.ID, err)
|
|
||||||
return errors.Wrap(err, "setting fields")
|
|
||||||
}
|
|
||||||
fields = *req.Fields
|
|
||||||
} else {
|
|
||||||
fields, err = s.DB.MemberFields(ctx, m.ID)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("getting fields for member %v: %v", m.ID, err)
|
|
||||||
return errors.Wrap(err, "getting fields")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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))
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -1,146 +0,0 @@
|
||||||
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"
|
|
||||||
"emperror.dev/errors"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/render"
|
|
||||||
"github.com/rs/xid"
|
|
||||||
)
|
|
||||||
|
|
||||||
const MaxReasonLength = 2000
|
|
||||||
|
|
||||||
type CreateReportRequest struct {
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) createUserReport(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 u db.User
|
|
||||||
if id, err := xid.FromString(chi.URLParam(r, "id")); err == nil {
|
|
||||||
u, err = s.DB.User(ctx, 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")
|
|
||||||
}
|
|
||||||
} 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}
|
|
||||||
}
|
|
||||||
|
|
||||||
var req CreateReportRequest
|
|
||||||
err = render.Decode(r, &req)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(req.Reason) > MaxReasonLength {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot exceed 2000 characters"}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.DB.CreateReport(ctx, claims.UserID, u.ID, nil, req.Reason)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("creating report for %v: %v", u.ID, err)
|
|
||||||
return errors.Wrap(err, "creating report")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.NoContent(w, r)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) createMemberReport(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"}
|
|
||||||
}
|
|
||||||
|
|
||||||
var m db.Member
|
|
||||||
if id, err := xid.FromString(chi.URLParam(r, "id")); 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, "id"))
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrUserNotFound}
|
|
||||||
}
|
|
||||||
|
|
||||||
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
|
|
||||||
if err != nil {
|
|
||||||
if err == db.ErrMemberNotFound {
|
|
||||||
return server.APIError{Code: server.ErrMemberNotFound}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Errorf("getting user %v: %v", id, err)
|
|
||||||
return errors.Wrap(err, "getting user")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := s.DB.User(ctx, m.UserID)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("getting user %v: %v", m.UserID, err)
|
|
||||||
return errors.Wrap(err, "getting user")
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.DeletedAt != nil {
|
|
||||||
return server.APIError{Code: server.ErrMemberNotFound}
|
|
||||||
}
|
|
||||||
|
|
||||||
var req CreateReportRequest
|
|
||||||
err = render.Decode(r, &req)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(req.Reason) > MaxReasonLength {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot exceed 2000 characters"}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.DB.CreateReport(ctx, claims.UserID, u.ID, &m.ID, req.Reason)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("creating report for %v: %v", m.ID, err)
|
|
||||||
return errors.Wrap(err, "creating report")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.NoContent(w, r)
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -1,235 +0,0 @@
|
||||||
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"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/render"
|
|
||||||
"github.com/rs/xid"
|
|
||||||
)
|
|
||||||
|
|
||||||
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"`
|
|
||||||
MemberTitle *string `json:"member_title"`
|
|
||||||
Avatar *string `json:"avatar"`
|
|
||||||
Links []string `json:"links"`
|
|
||||||
Names []db.FieldEntry `json:"names"`
|
|
||||||
Pronouns []db.PronounEntry `json:"pronouns"`
|
|
||||||
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"`
|
|
||||||
|
|
||||||
Tumblr *string `json:"tumblr"`
|
|
||||||
TumblrUsername *string `json:"tumblr_username"`
|
|
||||||
|
|
||||||
Google *string `json:"google"`
|
|
||||||
GoogleUsername *string `json:"google_username"`
|
|
||||||
|
|
||||||
Fediverse *string `json:"fediverse"`
|
|
||||||
FediverseUsername *string `json:"fediverse_username"`
|
|
||||||
FediverseInstance *string `json:"fediverse_instance"`
|
|
||||||
}
|
|
||||||
|
|
||||||
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"`
|
|
||||||
Avatar *string `json:"avatar"`
|
|
||||||
Links []string `json:"links"`
|
|
||||||
Names []db.FieldEntry `json:"names"`
|
|
||||||
Pronouns []db.PronounEntry `json:"pronouns"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags []db.UserFlag) GetUserResponse {
|
|
||||||
resp := GetUserResponse{
|
|
||||||
ID: u.ID,
|
|
||||||
SnowflakeID: u.SnowflakeID,
|
|
||||||
SID: u.SID,
|
|
||||||
Username: u.Username,
|
|
||||||
DisplayName: u.DisplayName,
|
|
||||||
Bio: u.Bio,
|
|
||||||
MemberTitle: u.MemberTitle,
|
|
||||||
Avatar: u.Avatar,
|
|
||||||
Links: db.NotNull(u.Links),
|
|
||||||
Names: db.NotNull(u.Names),
|
|
||||||
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,
|
|
||||||
Avatar: members[i].Avatar,
|
|
||||||
Links: db.NotNull(members[i].Links),
|
|
||||||
Names: db.NotNull(members[i].Names),
|
|
||||||
Pronouns: db.NotNull(members[i].Pronouns),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err 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)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("getting user by ID: %v", 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.ID.IsNil() {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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("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")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 errors.Wrap(err, "getting user members")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, dbUserToResponse(u, fields, members, flags))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) getMeUser(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 {
|
|
||||||
log.Errorf("Error getting user: %v", err)
|
|
||||||
return errors.Wrap(err, "getting users")
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, GetMeResponse{
|
|
||||||
GetUserResponse: dbUserToResponse(u, fields, members, flags),
|
|
||||||
CreatedAt: u.ID.Time(),
|
|
||||||
Timezone: u.Timezone,
|
|
||||||
MaxInvites: u.MaxInvites,
|
|
||||||
IsAdmin: u.IsAdmin,
|
|
||||||
ListPrivate: u.ListPrivate,
|
|
||||||
LastSIDReroll: u.LastSIDReroll,
|
|
||||||
Discord: u.Discord,
|
|
||||||
DiscordUsername: u.DiscordUsername,
|
|
||||||
Tumblr: u.Tumblr,
|
|
||||||
TumblrUsername: u.TumblrUsername,
|
|
||||||
Google: u.Google,
|
|
||||||
GoogleUsername: u.GoogleUsername,
|
|
||||||
Fediverse: u.Fediverse,
|
|
||||||
FediverseUsername: u.FediverseUsername,
|
|
||||||
FediverseInstance: u.FediverseInstance,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -1,418 +0,0 @@
|
||||||
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"
|
|
||||||
"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"`
|
|
||||||
DisplayName *string `json:"display_name"`
|
|
||||||
Bio *string `json:"bio"`
|
|
||||||
MemberTitle *string `json:"member_title"`
|
|
||||||
Links *[]string `json:"links"`
|
|
||||||
Names *[]db.FieldEntry `json:"names"`
|
|
||||||
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.
|
|
||||||
func (s *Server) patchUser(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"}
|
|
||||||
}
|
|
||||||
|
|
||||||
var req PatchUserRequest
|
|
||||||
err := render.Decode(r, &req)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
|
||||||
}
|
|
||||||
|
|
||||||
// get existing user, for comparison later
|
|
||||||
u, err := s.DB.User(ctx, claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting existing user")
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate that *something* is set
|
|
||||||
if req.Username == nil &&
|
|
||||||
req.DisplayName == nil &&
|
|
||||||
req.Bio == nil &&
|
|
||||||
req.MemberTitle == nil &&
|
|
||||||
req.ListPrivate == nil &&
|
|
||||||
req.Links == nil &&
|
|
||||||
req.Fields == nil &&
|
|
||||||
req.Names == nil &&
|
|
||||||
req.Pronouns == nil &&
|
|
||||||
req.Avatar == nil &&
|
|
||||||
req.CustomPreferences == nil &&
|
|
||||||
req.Flags == nil {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: "Data must not be empty",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate display name/bio
|
|
||||||
if common.StringLength(req.Username) > db.MaxUsernameLength {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxUsernameLength, common.StringLength(req.Username)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if common.StringLength(req.DisplayName) > db.MaxDisplayNameLength {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, common.StringLength(req.DisplayName)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if common.StringLength(req.Bio) > db.MaxUserBioLength {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, common.StringLength(req.Bio)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("Too many links (max %d, current %d)", db.MaxUserLinksLength, len(*req.Links)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, link := range *req.Links {
|
|
||||||
if len(link) > db.MaxLinkLength {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("Link %d too long (max %d, current %d)", i, db.MaxLinkLength, len(link)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate custom preferences
|
|
||||||
if req.CustomPreferences != nil {
|
|
||||||
if count := len(*req.CustomPreferences); count > db.MaxFields {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest, Details: fmt.Sprintf("Too many custom preferences (max %d, current %d)", db.MaxFields, count)}
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v := range *req.CustomPreferences {
|
|
||||||
_, err := uuid.Parse(k)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest, Details: "One or more custom preference IDs is not a UUID."}
|
|
||||||
}
|
|
||||||
if s := v.Validate(); s != "" {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest, Details: s}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
if req.Avatar != nil {
|
|
||||||
if *req.Avatar == "" {
|
|
||||||
if u.Avatar != nil {
|
|
||||||
err = s.DB.DeleteUserAvatar(ctx, u.ID, *u.Avatar)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("deleting user avatar: %v", err)
|
|
||||||
return errors.Wrap(err, "deleting avatar")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
avatarHash = req.Avatar
|
|
||||||
} else {
|
|
||||||
webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar)
|
|
||||||
if err != nil {
|
|
||||||
if err == db.ErrInvalidDataURI {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: "invalid avatar data URI",
|
|
||||||
}
|
|
||||||
} else if err == db.ErrInvalidContentType {
|
|
||||||
return server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: "invalid avatar content type",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Errorf("converting user avatar: %v", err)
|
|
||||||
return errors.Wrap(err, "converting avatar")
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
avatarHash = &hash
|
|
||||||
|
|
||||||
// delete current avatar if user has one
|
|
||||||
if u.Avatar != nil {
|
|
||||||
err = s.DB.DeleteUserAvatar(ctx, claims.UserID, *u.Avatar)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("deleting existing avatar: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// start transaction
|
|
||||||
tx, err := s.DB.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// update username
|
|
||||||
if req.Username != nil && *req.Username != u.Username {
|
|
||||||
err = s.DB.UpdateUsername(ctx, tx, claims.UserID, *req.Username)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case db.ErrUsernameTaken:
|
|
||||||
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)
|
|
||||||
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
|
|
||||||
log.Errorf("updating user: %v", err)
|
|
||||||
return errors.Wrap(err, "updating user")
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Names != nil || req.Pronouns != nil {
|
|
||||||
names := u.Names
|
|
||||||
pronouns := u.Pronouns
|
|
||||||
|
|
||||||
if req.Names != nil {
|
|
||||||
names = *req.Names
|
|
||||||
}
|
|
||||||
if req.Pronouns != nil {
|
|
||||||
pronouns = *req.Pronouns
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
u.Names = names
|
|
||||||
u.Pronouns = pronouns
|
|
||||||
}
|
|
||||||
|
|
||||||
var fields []db.Field
|
|
||||||
if req.Fields != nil {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// get fedi instance name if the user has a linked fedi account
|
|
||||||
var fediInstance *string
|
|
||||||
if u.FediverseAppID != nil {
|
|
||||||
app, err := s.DB.FediverseAppByID(ctx, *u.FediverseAppID)
|
|
||||||
if err == nil {
|
|
||||||
fediInstance = &app.Instance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
MaxInvites: u.MaxInvites,
|
|
||||||
IsAdmin: u.IsAdmin,
|
|
||||||
ListPrivate: u.ListPrivate,
|
|
||||||
LastSIDReroll: u.LastSIDReroll,
|
|
||||||
Discord: u.Discord,
|
|
||||||
DiscordUsername: u.DiscordUsername,
|
|
||||||
Tumblr: u.Tumblr,
|
|
||||||
TumblrUsername: u.TumblrUsername,
|
|
||||||
Google: u.Google,
|
|
||||||
GoogleUsername: u.GoogleUsername,
|
|
||||||
Fediverse: u.Fediverse,
|
|
||||||
FediverseUsername: u.FediverseUsername,
|
|
||||||
FediverseInstance: fediInstance,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type validator interface {
|
|
||||||
Validate(custom db.CustomPreferences) string
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateSlicePtr validates a slice of validators.
|
|
||||||
// If the slice is nil, a nil error is returned (assuming that the field is not required)
|
|
||||||
func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError {
|
|
||||||
if slice == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
max := db.MaxFields
|
|
||||||
if typ != "field" {
|
|
||||||
max = db.FieldEntriesLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
// max 25 fields
|
|
||||||
if len(*slice) > max {
|
|
||||||
return &server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("Too many %ss (max %d, current %d)", typ, max, len(*slice)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate all fields
|
|
||||||
for i, pronouns := range *slice {
|
|
||||||
if s := pronouns.Validate(custom); s != "" {
|
|
||||||
return &server.APIError{
|
|
||||||
Code: server.ErrBadRequest,
|
|
||||||
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
|
||||||
"emperror.dev/errors"
|
|
||||||
"github.com/go-chi/render"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) GetSettings(w http.ResponseWriter, r *http.Request) (err error) {
|
|
||||||
claims, _ := server.ClaimsFromContext(r.Context())
|
|
||||||
u, err := s.DB.User(r.Context(), claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("getting user: %v", err)
|
|
||||||
return errors.Wrap(err, "getting user")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, u.Settings)
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -1,49 +0,0 @@
|
||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
|
||||||
"emperror.dev/errors"
|
|
||||||
"github.com/aarondl/opt/omitnull"
|
|
||||||
"github.com/go-chi/render"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PatchSettingsRequest struct {
|
|
||||||
ReadChangelog omitnull.Val[string] `json:"read_changelog"`
|
|
||||||
ReadSettingsNotice omitnull.Val[string] `json:"read_settings_notice"`
|
|
||||||
ReadGlobalNotice omitnull.Val[int] `json:"read_global_notice"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) PatchSettings(w http.ResponseWriter, r *http.Request) (err error) {
|
|
||||||
ctx := r.Context()
|
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
|
||||||
u, err := s.DB.User(ctx, claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting user")
|
|
||||||
}
|
|
||||||
|
|
||||||
var req PatchSettingsRequest
|
|
||||||
err = render.Decode(r, &req)
|
|
||||||
if err != nil {
|
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !req.ReadChangelog.IsUnset() {
|
|
||||||
u.Settings.ReadChangelog = req.ReadChangelog.GetOrZero()
|
|
||||||
}
|
|
||||||
if !req.ReadSettingsNotice.IsUnset() {
|
|
||||||
u.Settings.ReadSettingsNotice = req.ReadSettingsNotice.GetOrZero()
|
|
||||||
}
|
|
||||||
if !req.ReadGlobalNotice.IsUnset() {
|
|
||||||
u.Settings.ReadGlobalNotice = req.ReadGlobalNotice.GetOrZero()
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.DB.UpdateUserSettings(ctx, u.ID, u.Settings)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "updating user settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
render.JSON(w, r, u.Settings)
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Server struct {
|
|
||||||
*server.Server
|
|
||||||
}
|
|
||||||
|
|
||||||
func Mount(srv *server.Server, r chi.Router) {
|
|
||||||
s := &Server{
|
|
||||||
Server: srv,
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Route("/users", func(r chi.Router) {
|
|
||||||
r.With(server.MustAuth).Group(func(r chi.Router) {
|
|
||||||
r.Get("/@me/settings", server.WrapHandler(s.GetSettings))
|
|
||||||
r.Patch("/@me/settings", server.WrapHandler(s.PatchSettings))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -5,8 +5,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server/auth"
|
"codeberg.org/u1f320/pronouns.cc/backend/server/auth"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -6,8 +6,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
|
@ -47,11 +46,14 @@ func New() *Verifier {
|
||||||
return &Verifier{key: key}
|
return &Verifier{key: key}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExpireDays is after how many days the token will expire.
|
||||||
|
const ExpireDays = 30
|
||||||
|
|
||||||
// CreateToken creates a token for the given user ID.
|
// CreateToken creates a token for the given user ID.
|
||||||
// It expires after three months.
|
// It expires after 30 days.
|
||||||
func (v *Verifier) CreateToken(userID, tokenID xid.ID, isAdmin bool, isAPIToken bool, isWriteToken bool) (token string, err error) {
|
func (v *Verifier) CreateToken(userID, tokenID xid.ID, isAdmin bool, isAPIToken bool, isWriteToken bool) (token string, err error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
expires := now.Add(db.TokenExpiryTime)
|
expires := now.Add(ExpireDays * 24 * time.Hour)
|
||||||
|
|
||||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
|
t := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
|
|
@ -1,14 +1,10 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||||
"emperror.dev/errors"
|
|
||||||
"github.com/getsentry/sentry-go"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -16,11 +12,6 @@ import (
|
||||||
// The inner HandlerFunc additionally returns an error.
|
// The inner HandlerFunc additionally returns an error.
|
||||||
func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
|
func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
hub := sentry.GetHubFromContext(r.Context())
|
|
||||||
if hub == nil {
|
|
||||||
hub = sentry.CurrentHub().Clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
err := hn(w, r)
|
err := hn(w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// if the function returned an API error, just render that verbatim
|
// if the function returned an API error, just render that verbatim
|
||||||
|
@ -33,20 +24,10 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rctx := chi.RouteContext(r.Context())
|
// otherwise, we log the error and return an internal server error message
|
||||||
hub.ConfigureScope(func(scope *sentry.Scope) {
|
log.Errorf("error in http handler: %v", err)
|
||||||
scope.SetTag("method", rctx.RouteMethod)
|
|
||||||
scope.SetTag("path", rctx.RoutePattern())
|
|
||||||
})
|
|
||||||
|
|
||||||
var eventID *sentry.EventID = nil
|
apiErr := APIError{Code: ErrInternalServerError}
|
||||||
if isExpectedError(err) {
|
|
||||||
log.Infof("expected error in handler for %v %v, ignoring", rctx.RouteMethod, rctx.RoutePattern())
|
|
||||||
} else {
|
|
||||||
log.Errorf("error in handler for %v %v: %v", rctx.RouteMethod, rctx.RoutePattern(), err)
|
|
||||||
eventID = hub.CaptureException(err)
|
|
||||||
}
|
|
||||||
apiErr := APIError{ID: eventID, Code: ErrInternalServerError}
|
|
||||||
apiErr.prepare()
|
apiErr.prepare()
|
||||||
|
|
||||||
render.Status(r, apiErr.Status)
|
render.Status(r, apiErr.Status)
|
||||||
|
@ -55,15 +36,10 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isExpectedError(err error) bool {
|
|
||||||
return errors.Is(err, context.Canceled)
|
|
||||||
}
|
|
||||||
|
|
||||||
// APIError is an object returned by the API when an error occurs.
|
// APIError is an object returned by the API when an error occurs.
|
||||||
// It implements the error interface and can be returned by handlers.
|
// It implements the error interface and can be returned by handlers.
|
||||||
type APIError struct {
|
type APIError struct {
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
ID *sentry.EventID `json:"id,omitempty"`
|
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
Details string `json:"details,omitempty"`
|
Details string `json:"details,omitempty"`
|
||||||
|
|
||||||
|
@ -121,13 +97,9 @@ const (
|
||||||
ErrAlreadyLinked = 1014 // user already has linked account of the same type
|
ErrAlreadyLinked = 1014 // user already has linked account of the same type
|
||||||
ErrNotLinked = 1015 // user already doesn't have a linked account
|
ErrNotLinked = 1015 // user already doesn't have a linked account
|
||||||
ErrLastProvider = 1016 // unlinking provider would leave account with no authentication method
|
ErrLastProvider = 1016 // unlinking provider would leave account with no authentication method
|
||||||
ErrInvalidCaptcha = 1017 // invalid or missing captcha response
|
|
||||||
|
|
||||||
// User-related error codes
|
// User-related error codes
|
||||||
ErrUserNotFound = 2001
|
ErrUserNotFound = 2001
|
||||||
ErrMemberListPrivate = 2002
|
|
||||||
ErrFlagLimitReached = 2003
|
|
||||||
ErrRerollingTooQuickly = 2004
|
|
||||||
|
|
||||||
// Member-related error codes
|
// Member-related error codes
|
||||||
ErrMemberNotFound = 3001
|
ErrMemberNotFound = 3001
|
||||||
|
@ -168,12 +140,8 @@ var errCodeMessages = map[int]string{
|
||||||
ErrAlreadyLinked: "Your account is already linked to an account of this type",
|
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",
|
ErrNotLinked: "Your account is already not linked to an account of this type",
|
||||||
ErrLastProvider: "This is your account's only authentication provider",
|
ErrLastProvider: "This is your account's only authentication provider",
|
||||||
ErrInvalidCaptcha: "Invalid or missing captcha response",
|
|
||||||
|
|
||||||
ErrUserNotFound: "User not found",
|
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.",
|
|
||||||
|
|
||||||
ErrMemberNotFound: "Member not found",
|
ErrMemberNotFound: "Member not found",
|
||||||
ErrMemberLimitReached: "Member limit reached",
|
ErrMemberLimitReached: "Member limit reached",
|
||||||
|
@ -211,12 +179,8 @@ var errCodeStatuses = map[int]int{
|
||||||
ErrAlreadyLinked: http.StatusBadRequest,
|
ErrAlreadyLinked: http.StatusBadRequest,
|
||||||
ErrNotLinked: http.StatusBadRequest,
|
ErrNotLinked: http.StatusBadRequest,
|
||||||
ErrLastProvider: http.StatusBadRequest,
|
ErrLastProvider: http.StatusBadRequest,
|
||||||
ErrInvalidCaptcha: http.StatusBadRequest,
|
|
||||||
|
|
||||||
ErrUserNotFound: http.StatusNotFound,
|
ErrUserNotFound: http.StatusNotFound,
|
||||||
ErrMemberListPrivate: http.StatusForbidden,
|
|
||||||
ErrFlagLimitReached: http.StatusBadRequest,
|
|
||||||
ErrRerollingTooQuickly: http.StatusForbidden,
|
|
||||||
|
|
||||||
ErrMemberNotFound: http.StatusNotFound,
|
ErrMemberNotFound: http.StatusNotFound,
|
||||||
ErrMemberLimitReached: http.StatusBadRequest,
|
ErrMemberLimitReached: http.StatusBadRequest,
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -6,15 +6,13 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server/auth"
|
"codeberg.org/u1f320/pronouns.cc/backend/server/auth"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server/rate"
|
"codeberg.org/u1f320/pronouns.cc/backend/server/rate"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
|
||||||
"github.com/go-chi/httprate"
|
"github.com/go-chi/httprate"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
chiprometheus "github.com/toshi0607/chi-prometheus"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Revision is the git commit, filled at build time
|
// Revision is the git commit, filled at build time
|
||||||
|
@ -24,7 +22,7 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository is the URL of the git repository
|
// 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 {
|
type Server struct {
|
||||||
Router *chi.Mux
|
Router *chi.Mux
|
||||||
|
@ -50,25 +48,6 @@ func New() (*Server, error) {
|
||||||
s.Router.Use(middleware.Logger)
|
s.Router.Use(middleware.Logger)
|
||||||
}
|
}
|
||||||
s.Router.Use(middleware.Recoverer)
|
s.Router.Use(middleware.Recoverer)
|
||||||
// add Sentry tracing handler
|
|
||||||
s.Router.Use(s.sentry)
|
|
||||||
|
|
||||||
// add CORS
|
|
||||||
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"},
|
|
||||||
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)
|
// enable authentication for all routes (but don't require it)
|
||||||
s.Router.Use(s.maybeAuth)
|
s.Router.Use(s.maybeAuth)
|
||||||
|
|
||||||
|
@ -100,35 +79,30 @@ func New() (*Server, error) {
|
||||||
|
|
||||||
// set scopes
|
// set scopes
|
||||||
// users
|
// users
|
||||||
_ = rateLimiter.Scope("GET", "/users/*", 60)
|
rateLimiter.Scope("GET", "/users/*", 60)
|
||||||
_ = rateLimiter.Scope("PATCH", "/users/@me", 10)
|
rateLimiter.Scope("PATCH", "/users/@me", 10)
|
||||||
|
|
||||||
// members
|
// members
|
||||||
_ = rateLimiter.Scope("GET", "/users/*/members", 60)
|
rateLimiter.Scope("GET", "/users/*/members", 60)
|
||||||
_ = rateLimiter.Scope("GET", "/users/*/members/*", 60)
|
rateLimiter.Scope("GET", "/users/*/members/*", 60)
|
||||||
|
|
||||||
_ = rateLimiter.Scope("POST", "/members", 10)
|
rateLimiter.Scope("POST", "/members", 10)
|
||||||
_ = rateLimiter.Scope("GET", "/members/*", 60)
|
rateLimiter.Scope("GET", "/members/*", 60)
|
||||||
_ = rateLimiter.Scope("PATCH", "/members/*", 20)
|
rateLimiter.Scope("PATCH", "/members/*", 20)
|
||||||
_ = rateLimiter.Scope("DELETE", "/members/*", 5)
|
rateLimiter.Scope("DELETE", "/members/*", 5)
|
||||||
|
|
||||||
// auth
|
// auth
|
||||||
_ = rateLimiter.Scope("*", "/auth/*", 20)
|
rateLimiter.Scope("*", "/auth/*", 20)
|
||||||
_ = rateLimiter.Scope("*", "/auth/tokens", 10)
|
rateLimiter.Scope("*", "/auth/tokens", 10)
|
||||||
_ = rateLimiter.Scope("*", "/auth/invites", 10)
|
rateLimiter.Scope("*", "/auth/invites", 10)
|
||||||
_ = rateLimiter.Scope("POST", "/auth/discord/*", 10)
|
rateLimiter.Scope("POST", "/auth/discord/*", 10)
|
||||||
|
|
||||||
|
// rate limit handling
|
||||||
|
// - 120 req/minute (2/s)
|
||||||
|
// - keyed by Authorization header if valid token is provided, otherwise by IP
|
||||||
|
// - returns rate limit reset info in error
|
||||||
s.Router.Use(rateLimiter.Handler())
|
s.Router.Use(rateLimiter.Handler())
|
||||||
|
|
||||||
// increment the total requests counter whenever a request is made
|
|
||||||
s.Router.Use(func(next http.Handler) http.Handler {
|
|
||||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.DB.TotalRequests.Inc()
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
return http.HandlerFunc(fn)
|
|
||||||
})
|
|
||||||
|
|
||||||
// return an API error for not found + method not allowed
|
// return an API error for not found + method not allowed
|
||||||
s.Router.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
s.Router.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||||
render.Status(r, errCodeStatuses[ErrNotFound])
|
render.Status(r, errCodeStatuses[ErrNotFound])
|
||||||
|
|
1
docs/.vitepress/.gitignore
vendored
1
docs/.vitepress/.gitignore
vendored
|
@ -1 +0,0 @@
|
||||||
cache/
|
|
|
@ -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" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,4 +0,0 @@
|
||||||
:root {
|
|
||||||
--vp-font-family-base: "FiraGO", sans-serif;
|
|
||||||
--vp-font-family-mono: "Fira Mono", monospace;
|
|
||||||
}
|
|
|
@ -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
|
|
|
@ -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 |
|
|
|
@ -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`.
|
|
|
@ -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 |
|
|
|
@ -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`.
|
|
|
@ -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. |
|
|
|
@ -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` |
|
|
|
@ -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 | |
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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.*
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue